Skip to content

Commit c53aea6

Browse files
authored
Merge pull request microsoft#1986 from tyrielv/tyrielv/service-telemetry-pipe
GVFS.Service: defer and retry telemetry pipe attachment
2 parents 781ff2c + b2c81a1 commit c53aea6

7 files changed

Lines changed: 535 additions & 3 deletions

File tree

GVFS/GVFS.Common/NativeMethods.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,19 @@ private static extern bool DeviceIoControl(
247247
[DllImport("kernel32.dll")]
248248
private static extern ulong GetTickCount64();
249249

250+
[DllImport("kernel32.dll")]
251+
private static extern int WTSGetActiveConsoleSessionId();
252+
253+
/// <summary>
254+
/// Returns the session ID of the physical console session, or -1 if
255+
/// no interactive session is active (e.g. at boot before logon).
256+
/// </summary>
257+
public static int GetActiveConsoleSessionId()
258+
{
259+
int sessionId = WTSGetActiveConsoleSessionId();
260+
return sessionId == unchecked((int)0xFFFFFFFF) ? -1 : sessionId;
261+
}
262+
250263
[DllImport("kernel32.dll", SetLastError = true)]
251264
private static extern bool SetFileTime(
252265
SafeFileHandle hFile,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using System.Collections.Concurrent;
2+
3+
namespace GVFS.Common.Tracing
4+
{
5+
/// <summary>
6+
/// An EventListener that buffers telemetry messages in memory. After
7+
/// a real listener is attached via <see cref="ReplayAndStop"/>, buffered
8+
/// messages are replayed and this listener becomes a no-op.
9+
/// </summary>
10+
public class BufferingTelemetryListener : EventListener
11+
{
12+
public const int DefaultMaxBufferedMessages = 1000;
13+
14+
private ConcurrentQueue<TraceEventMessage> buffer = new ConcurrentQueue<TraceEventMessage>();
15+
private readonly int maxBufferedMessages;
16+
private volatile bool stopped;
17+
18+
public BufferingTelemetryListener(int maxBufferedMessages = DefaultMaxBufferedMessages)
19+
: base(EventLevel.Verbose, Keywords.Telemetry, eventSink: null)
20+
{
21+
this.maxBufferedMessages = maxBufferedMessages;
22+
}
23+
24+
/// <summary>
25+
/// Number of messages currently buffered.
26+
/// </summary>
27+
public int BufferedCount => this.buffer?.Count ?? 0;
28+
29+
/// <summary>
30+
/// Whether this listener has been stopped (replay completed).
31+
/// </summary>
32+
public bool IsStopped => this.stopped;
33+
34+
/// <summary>
35+
/// Replays all buffered messages to <paramref name="target"/> and
36+
/// stops further buffering. This listener remains in the tracer's
37+
/// listener list but becomes a no-op. Safe to call multiple times;
38+
/// only the first call replays.
39+
/// </summary>
40+
/// <returns>Number of messages replayed.</returns>
41+
public int ReplayAndStop(EventListener target)
42+
{
43+
if (this.stopped)
44+
{
45+
return 0;
46+
}
47+
48+
this.stopped = true;
49+
ConcurrentQueue<TraceEventMessage> queue = this.buffer;
50+
this.buffer = null;
51+
52+
int count = 0;
53+
if (queue != null)
54+
{
55+
while (queue.TryDequeue(out TraceEventMessage message))
56+
{
57+
target.RecordMessage(message);
58+
count++;
59+
}
60+
}
61+
62+
return count;
63+
}
64+
65+
protected override void RecordMessageInternal(TraceEventMessage message)
66+
{
67+
if (this.stopped)
68+
{
69+
return;
70+
}
71+
72+
// Soft cap: under high concurrency, a few messages may exceed
73+
// maxBufferedMessages because Count and Enqueue are not atomic.
74+
// This is acceptable — the cap prevents unbounded growth, and
75+
// a small overshoot is harmless.
76+
ConcurrentQueue<TraceEventMessage> queue = this.buffer;
77+
if (queue != null && queue.Count < this.maxBufferedMessages)
78+
{
79+
queue.Enqueue(message);
80+
}
81+
}
82+
}
83+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
using System;
2+
using System.Threading;
3+
4+
namespace GVFS.Common.Tracing
5+
{
6+
/// <summary>
7+
/// Manages deferred telemetry pipe attachment for processes that cannot
8+
/// read the pipe config at startup (e.g. GVFS.Service running as SYSTEM,
9+
/// or any process started before the telemetry collector is installed).
10+
///
11+
/// Adds a <see cref="BufferingTelemetryListener"/> to the tracer at
12+
/// construction time, then periodically retries creating a real
13+
/// <see cref="TelemetryDaemonEventListener"/>. On success, buffered
14+
/// messages are replayed and the retry timer stops.
15+
///
16+
/// Callers can also trigger an explicit attach attempt via
17+
/// <see cref="TryAttach(string)"/> — e.g. on session logon when the
18+
/// user's HOME is available.
19+
///
20+
/// Designed for reuse by both GVFS.Service and GVFS.Mount.
21+
/// </summary>
22+
public class DeferredTelemetryAttacher : IDisposable
23+
{
24+
private readonly JsonTracer tracer;
25+
private readonly BufferingTelemetryListener buffer;
26+
private readonly string providerName;
27+
private readonly string enlistmentId;
28+
private readonly string mountId;
29+
private readonly Lock attachLock = new Lock();
30+
31+
private Timer retryTimer;
32+
private string retryGitBinRoot;
33+
private int retryCount;
34+
private bool attached;
35+
private bool disposed;
36+
37+
public DeferredTelemetryAttacher(
38+
JsonTracer tracer,
39+
string providerName,
40+
string enlistmentId,
41+
string mountId)
42+
{
43+
this.tracer = tracer;
44+
this.providerName = providerName;
45+
this.enlistmentId = enlistmentId;
46+
this.mountId = mountId;
47+
this.buffer = new BufferingTelemetryListener();
48+
tracer.AddEventListener(this.buffer);
49+
}
50+
51+
public bool IsAttached
52+
{
53+
get
54+
{
55+
lock (this.attachLock)
56+
{
57+
return this.attached;
58+
}
59+
}
60+
}
61+
62+
/// <summary>
63+
/// Starts a background retry timer that periodically calls
64+
/// <see cref="TryAttach"/> with the given gitBinRoot. Uses
65+
/// exponential backoff: 10s, 30s, 1m, then 5m steady state.
66+
/// </summary>
67+
public void StartRetryTimer(string gitBinRoot)
68+
{
69+
lock (this.attachLock)
70+
{
71+
if (this.attached || this.disposed || this.retryTimer != null)
72+
{
73+
return;
74+
}
75+
76+
this.retryGitBinRoot = gitBinRoot;
77+
this.retryCount = 0;
78+
this.retryTimer = new Timer(
79+
this.OnRetryTimer,
80+
null,
81+
GetRetryInterval(0),
82+
Timeout.Infinite);
83+
}
84+
}
85+
86+
/// <summary>
87+
/// Attempts to create and attach a TelemetryDaemonEventListener.
88+
/// Call this when environment conditions change (e.g. user session
89+
/// becomes available). Replays buffered messages on success.
90+
/// Safe to call multiple times — no-ops after first successful attach.
91+
/// </summary>
92+
/// <param name="gitBinRoot">Path to git binary.</param>
93+
/// <param name="globalConfigPath">
94+
/// If non-null, reads this file with <c>git config --file</c> instead
95+
/// of <c>--global</c>. Use this when the caller needs to read another
96+
/// user's .gitconfig without mutating the process-wide HOME variable.
97+
/// </param>
98+
/// <returns>true if attached (now or previously).</returns>
99+
public bool TryAttach(string gitBinRoot, string globalConfigPath = null)
100+
{
101+
lock (this.attachLock)
102+
{
103+
if (this.attached || this.tracer.HasTelemetryDaemonListener)
104+
{
105+
return true;
106+
}
107+
108+
if (string.IsNullOrEmpty(gitBinRoot))
109+
{
110+
return false;
111+
}
112+
113+
TelemetryDaemonEventListener daemonListener;
114+
try
115+
{
116+
daemonListener = TelemetryDaemonEventListener.CreateIfEnabled(
117+
gitBinRoot,
118+
this.providerName,
119+
this.enlistmentId,
120+
this.mountId,
121+
this.tracer,
122+
globalConfigPath);
123+
}
124+
catch (Exception)
125+
{
126+
return false;
127+
}
128+
129+
if (daemonListener == null)
130+
{
131+
return false;
132+
}
133+
134+
this.tracer.AddEventListener(daemonListener);
135+
int replayed = this.buffer.ReplayAndStop(daemonListener);
136+
this.StopRetryTimer();
137+
this.attached = true;
138+
139+
this.tracer.RelatedInfo(
140+
"DeferredTelemetryAttacher: Attached, replayed {0} buffered messages",
141+
replayed);
142+
143+
return true;
144+
}
145+
}
146+
147+
public void Dispose()
148+
{
149+
lock (this.attachLock)
150+
{
151+
if (this.disposed)
152+
{
153+
return;
154+
}
155+
156+
this.disposed = true;
157+
this.StopRetryTimer();
158+
}
159+
}
160+
161+
internal static int GetRetryInterval(int retryCount)
162+
{
163+
return retryCount switch
164+
{
165+
0 => 10_000, // 10 seconds
166+
1 => 30_000, // 30 seconds
167+
2 => 60_000, // 1 minute
168+
_ => 300_000, // 5 minutes
169+
};
170+
}
171+
172+
private void StopRetryTimer()
173+
{
174+
// Must be called while holding attachLock
175+
if (this.retryTimer != null)
176+
{
177+
this.retryTimer.Dispose();
178+
this.retryTimer = null;
179+
}
180+
}
181+
182+
private void OnRetryTimer(object state)
183+
{
184+
try
185+
{
186+
bool success = this.TryAttach(this.retryGitBinRoot);
187+
if (!success)
188+
{
189+
lock (this.attachLock)
190+
{
191+
if (this.retryTimer != null && !this.disposed)
192+
{
193+
this.retryCount++;
194+
this.retryTimer.Change(
195+
GetRetryInterval(this.retryCount),
196+
Timeout.Infinite);
197+
}
198+
}
199+
}
200+
}
201+
catch (Exception)
202+
{
203+
// Swallow — timer will not reschedule, but the explicit
204+
// TryAttach path (e.g. on SessionLogon) can still succeed.
205+
}
206+
}
207+
}
208+
}

GVFS/GVFS.Common/Tracing/JsonTracer.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ public bool HasLogFileEventListener
8181
}
8282
}
8383

84+
public bool HasTelemetryDaemonListener
85+
{
86+
get
87+
{
88+
return this.listeners.Any(listener => listener is TelemetryDaemonEventListener);
89+
}
90+
}
91+
8492
public void SetGitCommandSessionId(string sessionId)
8593
{
8694
TelemetryDaemonEventListener daemonListener = this.listeners.FirstOrDefault(x => x is TelemetryDaemonEventListener) as TelemetryDaemonEventListener;

GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,22 @@ private TelemetryDaemonEventListener(
3737
public string GitCommandSessionId { get; set; }
3838

3939
public static TelemetryDaemonEventListener CreateIfEnabled(string gitBinRoot, string providerName, string enlistmentId, string mountId, IEventListenerEventSink eventSink)
40+
{
41+
return CreateIfEnabled(gitBinRoot, providerName, enlistmentId, mountId, eventSink, globalConfigPath: null);
42+
}
43+
44+
/// <summary>
45+
/// Creates a TelemetryDaemonEventListener if the telemetry pipe config
46+
/// is set. When <paramref name="globalConfigPath"/> is provided, reads
47+
/// that file directly instead of using <c>git config --global</c>.
48+
/// This avoids mutating the process-wide HOME environment variable
49+
/// when the caller needs to read another user's config (e.g.
50+
/// GVFS.Service reading the logged-on user's .gitconfig).
51+
/// </summary>
52+
public static TelemetryDaemonEventListener CreateIfEnabled(string gitBinRoot, string providerName, string enlistmentId, string mountId, IEventListenerEventSink eventSink, string globalConfigPath)
4053
{
4154
// This listener is disabled unless the user specifies the proper git config setting.
42-
string telemetryPipe = GetConfigValue(gitBinRoot, GVFSConstants.GitConfig.GVFSTelemetryPipe);
55+
string telemetryPipe = GetConfigValue(gitBinRoot, GVFSConstants.GitConfig.GVFSTelemetryPipe, globalConfigPath);
4356
if (!string.IsNullOrEmpty(telemetryPipe))
4457
{
4558
return new TelemetryDaemonEventListener(providerName, enlistmentId, mountId, telemetryPipe, eventSink);
@@ -90,15 +103,23 @@ protected override void RecordMessageInternal(TraceEventMessage message)
90103
}
91104
}
92105

93-
private static string GetConfigValue(string gitBinRoot, string configKey)
106+
private static string GetConfigValue(string gitBinRoot, string configKey, string globalConfigPath = null)
94107
{
95108
string value = string.Empty;
96109
string error;
97110

98111
GitProcess.ConfigResult result = GitProcess.GetFromSystemConfig(gitBinRoot, configKey);
99112
if (!result.TryParseAsString(out value, out error, defaultValue: string.Empty) || string.IsNullOrWhiteSpace(value))
100113
{
101-
result = GitProcess.GetFromGlobalConfig(gitBinRoot, configKey);
114+
if (!string.IsNullOrEmpty(globalConfigPath))
115+
{
116+
result = GitProcess.GetFromFileConfig(gitBinRoot, globalConfigPath, configKey);
117+
}
118+
else
119+
{
120+
result = GitProcess.GetFromGlobalConfig(gitBinRoot, configKey);
121+
}
122+
102123
result.TryParseAsString(out value, out error, defaultValue: string.Empty);
103124
}
104125

0 commit comments

Comments
 (0)