Skip to content

Commit d334b5a

Browse files
committed
Defer and retry telemetry pipe attachment for GVFS.Service
GVFS.Service runs as SYSTEM and cannot read the user's global git config (where gvfs.telemetry-pipe is set) at startup. This meant TelemetryDaemonEventListener was never created, and all service telemetry events (PendingUpgradeHandler, etc.) were silently lost. Add two new classes in GVFS.Common.Tracing: - BufferingTelemetryListener: an EventListener that buffers telemetry messages in a bounded ConcurrentQueue, then replays them to a real listener on demand. - DeferredTelemetryAttacher: manages the lifecycle of deferred telemetry pipe attachment. Adds a BufferingTelemetryListener to the tracer, then periodically retries creating the real daemon listener (exponential backoff: 10s, 30s, 1m, 5m steady state). On success, replays buffered messages and stops the timer. Checks HasTelemetryDaemonListener to prevent duplicate listeners when the JsonTracer constructor already attached one. Designed for reuse by both GVFS.Service and GVFS.Mount. TelemetryDaemonEventListener gains a globalConfigPath parameter so callers can read a specific .gitconfig file via --file instead of --global. This lets GVFSService read the logged-on user's config without mutating the process-wide HOME environment variable. GVFSService creates a DeferredTelemetryAttacher at startup and calls TryAttach on session logon events, passing the user's .gitconfig path resolved via RunImpersonated. JsonTracer gains only HasTelemetryDaemonListener (one-liner property) to support the duplicate-listener guard. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
1 parent 1e748bb commit d334b5a

6 files changed

Lines changed: 420 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)