Skip to content

Commit 8778ff4

Browse files
Emit real elapsed time from SlowOperation telemetry (single event)
Follow-up to #40269, which introduced SlowOperationWatcher. As noted in that PR's review, when the threshold timer fires we learn only that an operation was slow, not *how* slow -- it could have finished at 10.1 s or still be stuck at 60 s -- and "logging the elapsed time at scope exit could be the most useful" follow-up. This adds that, while keeping the watcher a passive, telemetry-only observer that emits **at most one** event. Behavior (exactly one of): - Fast path: the operation finishes under SlowThreshold (10 s default) -- the common case. Nothing is emitted. - Slow completion: it finishes but took >= SlowThreshold. On scope exit (destructor / Reset()) one `timedOut=false` SlowOperation event is emitted with the real `elapsedMs` since construction -- the authoritative total duration. - Hang backstop: it is still running at MaxDuration (15 min default). A single-shot timer fires once and emits one `timedOut=true` event (elapsedMs ~= MaxDuration), then stops -- so an operation that never returns (e.g. an HCS wait that blocks INFINITE, where the destructor never runs) is still reported exactly once instead of being invisible. It never repeats. So SlowThreshold is the "slow enough to report at completion" filter and MaxDuration is the "give up waiting and report the hang, once" deadline. m_reported (atomic) guarantees at most one event across the timer callback, Reset(), and the destructor. The event schema gains `elapsedMs` (Int64) and `completed` (Boolean); the existing `name`, `thresholdMs`, `file`, `function`, `line` fields are unchanged (additive, backward compatible). Compatibility / safety: - The two new constructor parameters (MaxDuration, OnSlow) are defaulted, so every existing call site compiles and behaves identically; the fast path emits nothing. - Only WSL_LOG_TELEMETRY is emitted (which also lands in ETL) -- no second log. - The threadpool callback dereferences the watcher, but Finish() resets the timer (cancel + drain in-flight callback) before any member is destroyed, so there is no use-after-free. The emission path is fully noexcept + CATCH_LOG guarded, so a telemetry failure can never crash or alter the watched operation. - No call sites are added or moved; the change is confined to the watcher class. Tests: adds test/windows/SlowOperationWatcherUnitTests.cpp (TAEF) covering the fast path, default-constructed fast path, slow completion (one completed=true with real elapsed), Reset() at-most-once, and the hang backstop (one completed=false at max, no second event on scope exit) -- via an injected recording sink. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f7fab85-2f28-4344-9454-b76e8d2cc4d4
1 parent 0fe081b commit 8778ff4

4 files changed

Lines changed: 477 additions & 38 deletions

File tree

src/windows/common/SlowOperationWatcher.cpp

Lines changed: 109 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,23 @@ Module Name:
88
99
Abstract:
1010
11-
See header for contract. A single-shot threadpool timer is armed for SlowThreshold
12-
in the constructor. If it fires, the callback emits one `SlowOperation` telemetry
13-
event with the phase name and captured std::source_location. The timer is owned by
14-
wil::unique_threadpool_timer, whose destroyer cancels pending callbacks and blocks
15-
for any in-flight callback before closing, so OnTimerFired cannot dereference
16-
`*this` after destruction.
11+
See header for contract. A single-shot threadpool timer is armed for MaxDuration in
12+
the constructor. Emission is at most one event, guarded by m_reported (atomic):
13+
14+
- If the operation finishes first (destructor or Reset()), Finish() cancels+drains
15+
the timer and, if the elapsed time is at least SlowThreshold, emits one
16+
timedOut=false event with the real elapsed time. Under-threshold => silent.
17+
- If the operation is still running at MaxDuration, the timer fires once and emits
18+
one timedOut=true event (elapsed ~= MaxDuration) as the hang backstop, then does
19+
nothing more; a later scope exit sees m_reported set and stays silent.
20+
21+
timedOut is a finished-vs-hung flag, NOT success-vs-failure: the watcher is a pure
22+
duration timer with no visibility into the operation's result, so a scope that exits
23+
by throwing is still reported timedOut=false.
24+
25+
The timer is owned by wil::unique_threadpool_timer, whose destroyer cancels the pending
26+
callback and blocks for any in-flight callback before closing, so OnTimerFired cannot
27+
dereference `*this` after destruction and m_reported is stable once the timer is drained.
1728
1829
--*/
1930

@@ -44,33 +55,115 @@ static_assert(Basename("C:\\src\\test.cpp") == "test.cpp");
4455
static_assert(Basename("no_separator.cpp") == "no_separator.cpp");
4556
} // namespace
4657

47-
SlowOperationWatcher::SlowOperationWatcher(_In_z_ const char* Name, std::chrono::milliseconds SlowThreshold, std::source_location Location) :
48-
m_name(Name), m_slowThreshold(SlowThreshold), m_location(Location)
58+
// clang-format off
59+
SlowOperationWatcher::SlowOperationWatcher(
60+
_In_z_ const char* Name,
61+
Duration SlowThreshold,
62+
Duration MaxDuration,
63+
Sink OnSlow,
64+
std::source_location Location) noexcept :
65+
// clang-format on
66+
m_name(Name),
67+
m_slowThreshold(SlowThreshold),
68+
m_maxDuration(MaxDuration),
69+
m_location(Location),
70+
m_start(std::chrono::steady_clock::now()),
71+
m_sink(OnSlow),
72+
m_reported(false)
4973
{
74+
// Internal invariants (all call sites pass compile-time constants, so a violation is a
75+
// programming error): a real sink, a positive threshold, and MaxDuration >= SlowThreshold.
76+
// The last one guarantees the hang backstop can never fire before the slow threshold, so a
77+
// timedOut=true record is never emitted for an operation that isn't yet "slow".
78+
WI_ASSERT(m_sink != nullptr);
79+
WI_ASSERT(m_slowThreshold.count() > 0);
80+
WI_ASSERT(m_maxDuration >= m_slowThreshold);
81+
82+
// Best-effort: this is a passive telemetry observer, so timer allocation failure must not
83+
// propagate into (and abort) the watched operation. If the timer can't be created we simply
84+
// run without the hang backstop -- the completion path (Finish() on scope exit / Reset())
85+
// still emits a timedOut=false record for a slow operation, since it does not depend on the timer.
5086
m_timer.reset(CreateThreadpoolTimer(OnTimerFired, this, nullptr));
51-
THROW_IF_NULL_ALLOC(m_timer.get());
87+
if (m_timer)
88+
{
89+
// Single-shot: fire once at MaxDuration as the hang backstop. If the operation finishes
90+
// first, Finish() cancels this before it fires. The due time is a 64-bit FILETIME, so it
91+
// represents any realistic MaxDuration without truncation; period 0 = one-shot.
92+
FILETIME due = RelativeFileTime(m_maxDuration);
93+
SetThreadpoolTimer(m_timer.get(), &due, 0, 0);
94+
}
95+
}
5296

53-
FILETIME due = RelativeFileTime(m_slowThreshold);
54-
SetThreadpoolTimer(m_timer.get(), &due, 0, 0);
97+
SlowOperationWatcher::~SlowOperationWatcher() noexcept
98+
{
99+
Finish();
55100
}
56101

57102
void SlowOperationWatcher::Reset() noexcept
58103
{
104+
Finish();
105+
}
106+
107+
void SlowOperationWatcher::Finish() noexcept
108+
{
109+
// Cancel the backstop timer and drain any in-flight callback. After this returns no
110+
// OnTimerFired can run, so m_reported is stable.
59111
m_timer.reset();
112+
113+
// The FIRST Finish() -- whether Reset() or the destructor -- permanently claims the
114+
// single report and decides emit-or-not from the elapsed time AT THIS MOMENT. This is
115+
// what makes Reset() an authoritative "operation ended here" marker: if the operation
116+
// was fast (< threshold) at Reset(), we still claim m_reported so the later destructor
117+
// cannot re-evaluate a larger elapsed (including post-Reset work) and emit a bogus
118+
// slow record. If it was slow, we emit exactly one timedOut=false record with the real
119+
// duration here, and the destructor is then a no-op. exchange also races safely against
120+
// the timer callback (which claims m_reported the same way).
121+
if (!m_reported.exchange(true))
122+
{
123+
const auto elapsed = Elapsed();
124+
if (elapsed >= m_slowThreshold)
125+
{
126+
Emit(false, elapsed);
127+
}
128+
}
129+
}
130+
131+
SlowOperationWatcher::Duration SlowOperationWatcher::Elapsed() const noexcept
132+
{
133+
return std::chrono::duration_cast<Duration>(std::chrono::steady_clock::now() - m_start);
134+
}
135+
136+
void SlowOperationWatcher::Emit(bool TimedOut, Duration Elapsed) noexcept
137+
{
138+
m_sink(Event{m_name, m_slowThreshold, Elapsed, TimedOut, m_location});
60139
}
61140

62141
void CALLBACK SlowOperationWatcher::OnTimerFired(PTP_CALLBACK_INSTANCE, PVOID Context, PTP_TIMER) noexcept
63142
try
64143
{
144+
// The single-shot timer reached MaxDuration while the operation is still running: emit the
145+
// hang backstop record once (timedOut=true, elapsed ~= MaxDuration) and stop. If Finish()
146+
// already reported (a race with a near-simultaneous scope exit), exchange keeps this a no-op.
65147
auto* self = static_cast<SlowOperationWatcher*>(Context);
148+
if (!self->m_reported.exchange(true))
149+
{
150+
self->Emit(true, self->Elapsed());
151+
}
152+
}
153+
CATCH_LOG()
66154

155+
void SlowOperationWatcher::EmitTelemetry(const Event& Record) noexcept
156+
try
157+
{
67158
WSL_LOG_TELEMETRY(
68159
"SlowOperation",
69160
PDT_ProductAndServicePerformance,
70-
TraceLoggingValue(self->m_name, "name"),
71-
TraceLoggingInt64(self->m_slowThreshold.count(), "thresholdMs"),
72-
TraceLoggingValue(Basename(self->m_location.file_name()).data(), "file"),
73-
TraceLoggingValue(self->m_location.function_name(), "function"),
74-
TraceLoggingUInt32(self->m_location.line(), "line"));
161+
TraceLoggingValue(Record.Name, "name"),
162+
TraceLoggingInt64(Record.Threshold.count(), "thresholdMs"),
163+
TraceLoggingInt64(Record.Elapsed.count(), "elapsedMs"),
164+
TraceLoggingBool(Record.TimedOut, "timedOut"),
165+
TraceLoggingValue(Basename(Record.Location.file_name()).data(), "file"),
166+
TraceLoggingValue(Record.Location.function_name(), "function"),
167+
TraceLoggingUInt32(Record.Location.line(), "line"));
75168
}
76169
CATCH_LOG()

src/windows/common/SlowOperationWatcher.h

Lines changed: 102 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,36 @@ Module Name:
88
99
Abstract:
1010
11-
RAII guard that watches a scoped operation. A threadpool timer is armed in the
12-
constructor for `SlowThreshold` (10 s default). On the fast path (scope exits
13-
before the threshold) the watcher's destructor cancels and drains the timer and
14-
nothing is emitted. If the threshold is reached first -- including while the
15-
scope is still running or hung indefinitely -- the timer callback fires once,
16-
emitting a single `SlowOperation` telemetry event carrying the phase name and
17-
the call site captured via std::source_location, so the backend can attribute
18-
where time is spent.
11+
RAII guard that times a scoped operation and emits **at most one** `SlowOperation`
12+
telemetry event. There are exactly three outcomes:
13+
14+
1. The operation finishes in under `SlowThreshold` (10 s default) -- the common,
15+
healthy case. Nothing is emitted.
16+
2. The operation finishes but took at least `SlowThreshold`. On the watched operation's
17+
end -- the destructor, or the earlier Reset() call site -- one `timedOut=false` event
18+
is emitted carrying the real `elapsedMs` measured from construction to that point (the
19+
full scope lifetime for the destructor case; up to the Reset() call when Reset() is
20+
used to disarm early). Note this says nothing about whether the operation *succeeded*:
21+
a scope that exits by throwing is still reported here (the watcher is a pure duration
22+
timer and has no visibility into the operation's result).
23+
3. The operation is still running after `MaxDuration` (15 min default) -- i.e. it is
24+
hung or pathologically slow. A single-shot timer fires once at `MaxDuration` and
25+
emits one `timedOut=true` event with `elapsedMs ~= MaxDuration`, then stops.
26+
This is the backstop for an operation that never returns (e.g. an HCS wait that
27+
blocks INFINITE, where the destructor never runs). It never repeats, and once it
28+
has fired the later scope exit does not emit a second event. This backstop is
29+
best-effort: if the threadpool timer cannot be created at construction, it is
30+
silently skipped (the watcher never affects the watched operation), so a hang
31+
may go unreported in that rare case. MaxDuration is the time a hang stays silent,
32+
so the default sits well above the longest legitimate operation timeout (the
33+
service's hard timeouts are on the order of minutes) to avoid misreporting a
34+
slow-but-valid operation, while still surfacing a true hang.
35+
36+
So `SlowThreshold` is the "slow enough to be worth reporting" filter applied at
37+
completion, and `MaxDuration` is the "give up waiting and report the hang" deadline.
38+
Every event carries the phase name and the call site (std::source_location). The
39+
watcher is a passive observer: it never affects the operation it measures, and a
40+
failure in the telemetry path can never crash or slow the watched code.
1941
2042
Usage:
2143
@@ -37,31 +59,68 @@ Module Name:
3759

3860
#include <windows.h>
3961
#include <wil/resource.h>
62+
#include <atomic>
4063
#include <chrono>
4164
#include <source_location>
4265

4366
class SlowOperationWatcher
4467
{
4568
public:
46-
// Name is restricted to a string-literal reference (const char (&)[N]) to guarantee
47-
// static storage duration: the raw pointer is dereferenced later from a threadpool
48-
// callback, so accepting a `const char*` would make UAF via a temporary (e.g.
49-
// std::string::c_str()) easy. Keep Name a short CamelCase phase identifier that the
50-
// backend query can switch on (e.g. "WaitForMiniInitConnect").
69+
// Alias for the millisecond durations used throughout the class (threshold, max, elapsed).
70+
using Duration = std::chrono::milliseconds;
71+
72+
// Contents of one SlowOperation telemetry record. Exposed so tests can observe
73+
// emissions through a custom sink without standing up an ETW listener.
74+
struct Event
75+
{
76+
const char* Name; // phase identifier; must outlive the watcher
77+
Duration Threshold; // configured slow threshold
78+
Duration Elapsed; // real time since construction
79+
bool TimedOut; // false: the scope finished (slow); Elapsed is the total.
80+
// true: still running at MaxDuration (hang backstop).
81+
// NOT a success/failure flag -- the watcher cannot know
82+
// the operation's result; a throwing scope is TimedOut=false.
83+
// By value, not a reference: a diagnostic sink may copy the Event and read it after
84+
// the watcher is destroyed, which would dangle a reference. std::source_location is
85+
// cheap to copy, so keep Event self-contained.
86+
std::source_location Location;
87+
};
88+
89+
// Receives the (at most one) SlowOperation record. Must be noexcept and must not block:
90+
// it is invoked either from the MaxDuration threadpool callback (timedOut=true) or from
91+
// Reset()/the destructor (timedOut=false). The default writes the telemetry event.
92+
using Sink = void (*)(const Event&) noexcept;
93+
94+
// Name is taken as a char-array reference (const char (&)[N]) rather than a const char*
95+
// to force callers to pass an array and block the easy UAF of a temporary's pointer
96+
// (e.g. std::string::c_str()): the raw pointer is dereferenced later from a threadpool
97+
// callback, so the pointed-to storage must outlive the watcher and any pending callback.
98+
// The array type does not by itself guarantee static storage -- a non-static array would
99+
// also compile -- so in practice always pass a string literal. Keep Name a short CamelCase
100+
// phase identifier that the backend query can switch on (e.g. "WaitForMiniInitConnect").
101+
// SlowThreshold is the "slow enough to report at completion" filter; MaxDuration is the
102+
// "report the hang and stop" deadline. OnSlow defaults to the telemetry emitter; tests
103+
// inject a recording sink.
51104
template <size_t N>
52105
explicit SlowOperationWatcher(
53106
const char (&Name)[N],
54-
std::chrono::milliseconds SlowThreshold = std::chrono::seconds{10},
55-
std::source_location Location = std::source_location::current()) :
56-
SlowOperationWatcher(static_cast<const char*>(Name), SlowThreshold, Location)
107+
Duration SlowThreshold = std::chrono::seconds{10},
108+
Duration MaxDuration = std::chrono::minutes{15},
109+
Sink OnSlow = &EmitTelemetry,
110+
std::source_location Location = std::source_location::current()) noexcept :
111+
SlowOperationWatcher(static_cast<const char*>(Name), SlowThreshold, MaxDuration, OnSlow, Location)
57112
{
58113
}
59114

60-
~SlowOperationWatcher() noexcept = default;
115+
// On destruction, emits the timedOut=false record if the operation was slow (>= threshold)
116+
// and the hang backstop hasn't already reported.
117+
~SlowOperationWatcher() noexcept;
61118

62-
// Disarm the watcher early. After Reset() returns, the threshold callback is
63-
// guaranteed not to fire. Relies on wil::unique_threadpool_timer's destroyer to
64-
// cancel pending callbacks and drain any in-flight one.
119+
// Disarm the watcher early (equivalent to the destructor, but at an explicit point). Cancels
120+
// and drains the MaxDuration timer, then -- if the operation took at least SlowThreshold and
121+
// the hang backstop hasn't already fired -- emits one timedOut=false record with the real
122+
// elapsed time. The fast path (under threshold) stays silent. Emits at most once across
123+
// Reset() + the destructor.
65124
void Reset() noexcept;
66125

67126
SlowOperationWatcher(const SlowOperationWatcher&) = delete;
@@ -70,12 +129,33 @@ class SlowOperationWatcher
70129
SlowOperationWatcher& operator=(SlowOperationWatcher&&) = delete;
71130

72131
private:
73-
explicit SlowOperationWatcher(_In_z_ const char* Name, std::chrono::milliseconds SlowThreshold, std::source_location Location);
132+
// clang-format off
133+
explicit SlowOperationWatcher(
134+
_In_z_ const char* Name,
135+
Duration SlowThreshold,
136+
Duration MaxDuration,
137+
Sink OnSlow,
138+
std::source_location Location) noexcept;
139+
// clang-format on
74140

75141
static void CALLBACK OnTimerFired(PTP_CALLBACK_INSTANCE, PVOID Context, PTP_TIMER) noexcept;
76142

143+
// Default sink: writes the SlowOperation telemetry event.
144+
static void EmitTelemetry(const Event& Record) noexcept;
145+
146+
Duration Elapsed() const noexcept;
147+
void Emit(bool TimedOut, Duration Elapsed) noexcept;
148+
149+
// Cancel + drain the timer, then emit the timedOut=false record if the operation was slow
150+
// and the hang backstop hasn't already reported.
151+
void Finish() noexcept;
152+
77153
const char* const m_name;
78-
const std::chrono::milliseconds m_slowThreshold;
154+
const Duration m_slowThreshold;
155+
const Duration m_maxDuration;
79156
const std::source_location m_location;
157+
const std::chrono::steady_clock::time_point m_start;
158+
const Sink m_sink;
159+
std::atomic<bool> m_reported; // set once the single event has been emitted (by either path)
80160
wil::unique_threadpool_timer m_timer;
81161
};

test/windows/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ set(SOURCES
1010
PolicyTests.cpp
1111
InstallerTests.cpp
1212
WSLCTests.cpp
13+
SlowOperationWatcherUnitTests.cpp
1314
WslcSdkTests.cpp
1415
WslcSdkWinRtTests.cpp
1516
WindowsUpdateTests.cpp)

0 commit comments

Comments
 (0)