From 291a56764bf7ff908a27e7c5f050471a108580e7 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Mon, 1 Jun 2026 10:38:17 -0700 Subject: [PATCH] Fix deferred telemetry pipe attach for RDP sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At startup, the deferred telemetry attacher only checked the physical console session via WTSGetActiveConsoleSessionId. On Cloud PCs and RDP-only machines, the console session has no logged-in user — the real user is in an RDP session. This meant the startup attach always failed, and the retry timer could never succeed either (SYSTEM has no git global config). A SessionLogon event would fix it, but that only fires for NEW logins — not when the service restarts while a user is already connected. Enumerate all interactive sessions (Active/Connected, session > 0) via WTSEnumerateSessions and try each until the pipe attaches. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Platform.Windows/CurrentUser.cs | 60 +++++++++++- GVFS/GVFS.Service/GVFSService.Windows.cs | 108 +++++++++++++++++----- 2 files changed, 141 insertions(+), 27 deletions(-) diff --git a/GVFS/GVFS.Platform.Windows/CurrentUser.cs b/GVFS/GVFS.Platform.Windows/CurrentUser.cs index 3fe329ae1..1d1ff151f 100644 --- a/GVFS/GVFS.Platform.Windows/CurrentUser.cs +++ b/GVFS/GVFS.Platform.Windows/CurrentUser.cs @@ -134,7 +134,7 @@ public bool RunAs(string processName, string args) if (CreateEnvironmentBlock(ref environment, duplicate, false)) { STARTUP_INFO info = new STARTUP_INFO(); - info.Length = Marshal.SizeOf(typeof(STARTUP_INFO)); + info.Length = Marshal.SizeOf(); PROCESS_INFORMATION procInfo = new PROCESS_INFORMATION(); if (CreateProcessAsUser( @@ -193,6 +193,55 @@ public bool RunAs(string processName, string args) return false; } + /// + /// Returns session IDs for sessions that have a logged-in user + /// whose token can be queried via WTSQueryUserToken. + /// + /// + /// + /// WTS session states relevant to telemetry pipe attachment: + /// + /// + /// StateMeaning + /// Active + /// User logged in, session connected (console or RDP). + /// Has a valid user token. This is the primary case. + /// + /// Connected + /// Client attached but no user logged in (e.g. the console + /// session showing the Windows login screen on a Cloud PC). + /// No user token — WTSQueryUserToken will fail. + /// + /// Disconnected + /// User logged in but client disconnected (e.g. closed RDP + /// window without logging off). The user's profile, processes, + /// and token are still available. Included so the service can + /// attach telemetry even when no client is actively connected. + /// + /// + /// + /// Session 0 is the services session and never has a user token, + /// even when its state is Disconnected. Excluded by the ID > 0 + /// guard. + /// + /// + public static List GetInteractiveSessionIds(ITracer tracer) + { + List sessionIds = new List(); + List sessions = ListSessions(tracer); + foreach (WTS_SESSION_INFO session in sessions) + { + if (session.SessionID > 0 && + (session.State == ConnectionState.Active || + session.State == ConnectionState.Disconnected)) + { + sessionIds.Add(session.SessionID); + } + } + + return sessionIds; + } + public void Dispose() { if (this.token != IntPtr.Zero) @@ -216,7 +265,10 @@ private static IntPtr GetCurrentUserToken(ITracer tracer, int sessionId) } else { - TraceWin32Error(tracer, string.Format("Unable to query user token from session '{0}'.", sessionId)); + // Warning, not error: sessions without a logged-in user + // (e.g. the console session on a Cloud PC) are expected. + Win32Exception e = new Win32Exception(Marshal.GetLastWin32Error()); + tracer.RelatedWarning("Unable to query user token from session '{0}'. Exception: {1}", sessionId, e.Message); } return IntPtr.Zero; @@ -234,12 +286,12 @@ private static List ListSessions(ITracer tracer) int retval = WTSEnumerateSessions(IntPtr.Zero, 0, 1, ref sessionInfo, ref count); if (retval != 0) { - int dataSize = Marshal.SizeOf(typeof(WTS_SESSION_INFO)); + int dataSize = Marshal.SizeOf(); long current = sessionInfo.ToInt64(); for (int i = 0; i < count; i++) { - WTS_SESSION_INFO si = (WTS_SESSION_INFO)Marshal.PtrToStructure((IntPtr)current, typeof(WTS_SESSION_INFO)); + WTS_SESSION_INFO si = Marshal.PtrToStructure((IntPtr)current); current += dataSize; output.Add(si); diff --git a/GVFS/GVFS.Service/GVFSService.Windows.cs b/GVFS/GVFS.Service/GVFSService.Windows.cs index c7f125b83..3b60ba104 100644 --- a/GVFS/GVFS.Service/GVFSService.Windows.cs +++ b/GVFS/GVFS.Service/GVFSService.Windows.cs @@ -5,6 +5,7 @@ using GVFS.Platform.Windows; using GVFS.Service.Handlers; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.AccessControl; @@ -46,6 +47,46 @@ public void Run() metadata.Add("Version", ProcessHelper.GetCurrentProcessVersion()); this.tracer.RelatedEvent(EventLevel.Informational, $"{nameof(GVFSService)}_{nameof(this.Run)}", metadata); + // Set up deferred telemetry pipe attachment FIRST, before any + // telemetry-emitting work (particularly PendingUpgradeHandler, + // whose Deferred/Complete events we want to capture). + // + // The service runs as SYSTEM and can't read the user's global + // git config (where gvfs.telemetry-pipe is configured) at + // startup. The DeferredTelemetryAttacher adds a + // BufferingTelemetryListener that captures events in memory, + // then replays them once the real pipe listener attaches. + // + // Three attach paths exist: + // 1. TryAttachTelemetryPipeForAnySessions() below — tries + // all Active/Disconnected sessions immediately. + // 2. OnSessionChange (SessionLogon) — fires when a new user + // logs in after the service is already running. + // 3. StartRetryTimer — periodic retry (10s, 30s, 1m, 5m) + // as a fallback, reads system config only (no user + // config available without a session to impersonate). + string gitBinRoot = GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath(); + if (!string.IsNullOrEmpty(gitBinRoot)) + { + this.telemetryAttacher = new DeferredTelemetryAttacher( + this.tracer, + GVFSConstants.Service.ServiceName, + enlistmentId: null, + mountId: null); + this.telemetryAttacher.StartRetryTimer(gitBinRoot); + + // If a user is already logged in (e.g. service restart + // during active session), try attaching immediately by + // enumerating all interactive sessions. This is needed + // because WTSGetActiveConsoleSessionId only returns the + // physical console session, which on Cloud PCs / DevBoxes + // (RDP-only) has no logged-in user. The actual user is + // in an RDP session that the console-only check misses. + // SessionLogon events also won't fire for sessions that + // were already established before the service started. + this.TryAttachTelemetryPipeForAnySessions(); + } + // Check for a staged upgrade before doing anything else. // If no GVFS.Mount processes are running (typical at boot or after // unmount-all), copy staged files in-place and proceed normally. @@ -77,29 +118,6 @@ public void Run() { this.CheckEnableGitStatusCacheTokenFile(); - // Set up deferred telemetry pipe attachment. The service - // runs as SYSTEM and can't read the user's global git - // config at startup, so the daemon listener can't be - // created in the JsonTracer constructor. - string gitBinRoot = GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath(); - if (!string.IsNullOrEmpty(gitBinRoot)) - { - this.telemetryAttacher = new DeferredTelemetryAttacher( - this.tracer, - GVFSConstants.Service.ServiceName, - enlistmentId: null, - mountId: null); - this.telemetryAttacher.StartRetryTimer(gitBinRoot); - - // If a user is already logged in (e.g. service restart - // during active session), try attaching immediately. - int activeSession = NativeMethods.GetActiveConsoleSessionId(); - if (activeSession > 0) - { - this.TryAttachTelemetryPipeForSession(activeSession); - } - } - using (ITracer activity = this.tracer.StartActivity("EnsurePrjFltHealthy", EventLevel.Informational)) { // Make a best-effort to enable PrjFlt. Continue even if it fails. @@ -475,6 +493,50 @@ private void TryAttachTelemetryPipeForSession(int sessionId) } } + /// + /// Enumerates all interactive sessions (Active and Disconnected) + /// and tries to attach the telemetry pipe using each session's + /// user profile. Stops on the first successful attach. + /// + /// + /// This method exists because the console-only check + /// (WTSGetActiveConsoleSessionId) fails on Cloud PCs and + /// RDP-only machines where the console session is in the Connected + /// state (login screen, no user). Disconnected sessions are also + /// checked because an RDP user who disconnected without logging + /// off still has a valid token and git config. + /// + private void TryAttachTelemetryPipeForAnySessions() + { + if (this.telemetryAttacher == null || this.telemetryAttacher.IsAttached) + { + return; + } + + List sessionIds = CurrentUser.GetInteractiveSessionIds(this.tracer); + if (sessionIds.Count == 0) + { + this.tracer.RelatedInfo("TryAttachTelemetryPipeForAnySessions: No interactive sessions found"); + return; + } + + foreach (int sessionId in sessionIds) + { + this.TryAttachTelemetryPipeForSession(sessionId); + if (this.telemetryAttacher.IsAttached) + { + break; + } + } + + if (!this.telemetryAttacher.IsAttached) + { + this.tracer.RelatedWarning( + "TryAttachTelemetryPipeForAnySessions: Could not attach from any of {0} interactive session(s)", + sessionIds.Count); + } + } + private DirectorySecurity GetServiceDirectorySecurity(string serviceDataRootPath) { DirectorySecurity serviceDataRootSecurity;