Sur un contrôleur de domaine Windows Server 2025 virtualisé sous XCP‑ng, un service configuré en démarrage automatique qui instancie une Form WinForms — même sans l’afficher — peut figer à la fermeture d’autres processus SYSTEM. Voici l’analyse, la cause et les correctifs.
Contexte et symptômes observés
Le scénario typique ressemble à ceci : vous développez un service Windows qui, pour diverses raisons (boîte de dialogue cachée, icône de zone de notification, initialisation de ressources graphiques, etc.), référence WinForms ou WPF et instancie une Form au démarrage. Le service démarre, la machine redémarre, et vous constatez alors que des processus s’exécutant sous SYSTEM
(tâches planifiées élevées, outils d’administration type PsExec, scripts de déploiement, agents de sauvegarde, etc.) restent « bloqués » ou se figent lors de leur fermeture.
Exemple minimal incriminé :
protected override void OnStart(string[] args)
{
using (var f = new Form1()) { } // instanciation d’une UI dans un service
}
Les symptômes les plus courants :
- Un processus lancé en
NT AUTHORITY\SYSTEM
ne se termine pas correctement ; il reste en état « Exiting » ou « Not Responding ». - La fermeture du processus requiert un Terminate forcé via Process Explorer, ou l’attente se résout uniquement après un redémarrage.
- Aucun message d’erreur explicite dans l’Observateur d’événements, ou bien des traces génériques autour de User32/GDI, Service Control Manager et arrêts prolongés.
La cause profonde : l’isolation de la Session 0 et les composants UI
Depuis Windows Vista/Server 2008, les services s’exécutent dans la Session 0 isolée, conçue pour être non interactive. Les applications avec interface (WinForms, WPF, éléments GDI+, Microsoft.Win32.SystemEvents
, hooks de notifications, icônes de zone de notification, etc.) supposent l’existence d’une station de fenêtre et d’un desktop interactifs, ainsi qu’une pompe de messages STA fonctionnelle. En Session 0, ces hypothèses ne sont pas garanties — et souvent explicitement non supportées.
Même si vous « n’affichez » jamais la fenêtre, l’instanciation d’une Form
déclenche la création de fenêtres cachées, l’abonnement à des événements système (alimentation, sessions, minuteur), et la mise en place d’un fil dédié à la boucle de messages. Lors des séquences d’arrêt ou de diffusion de messages (shutdown, WM_QUERYENDSESSION
, BroadcastSystemMessage
), ces composants peuvent entrer dans des attentes croisées avec d’autres processus SYSTEM qui, eux, tentent de fermer proprement, provoquant des deadlocks ou des gel prolongés.
Sur un contrôleur de domaine, la surface de notifications système et de sécurité est plus importante (services AD DS, journalisation, stratégies). Les timings d’arrêt/fermeture sont plus sensibles, ce qui rend ces blocages plus fréquents. La virtualisation (ici XCP‑ng) peut accentuer des délais mais n’est pas la cause première : la racine du problème est l’UI dans un service.
Reproduction et signaux caractéristiques
- Créer un service .NET qui référence
System.Windows.Forms
et instancie uneForm
durantOnStart
(même si elle n’est jamais affichée). - Définir le service en Automatic (idéalement Automatic (Delayed Start) pour stabiliser l’environnement, mais cela n’élimine pas le problème).
- Redémarrer le serveur, ouvrir une session administrateur.
- Lancer un binaire sous SYSTEM (ex. via PsExec
-s
ou une Tâche planifiée en « Exécuter avec les autorisations les plus élevées »). - Observer le gel à la fermeture : le processus reste en mémoire, parfois sans pile évidente côté utilisateur.
Outils d’observation efficaces :
- Process Explorer : inspecter les fenêtres cachées, les chaînes d’attente (Wait Chain) et les threads bloqués.
- Resource Monitor : menu « Analyser la chaîne d’attente » sur le processus gelé.
- ProcDump : générer un dump mémoire au moment du blocage —
procdump -ma <pid>
. - Observateur d’événements : canaux Application/System, événements du Service Control Manager, arrêts prolongés.
Conclusion fonctionnelle en une phrase
Un service Windows ne doit pas créer d’UI. Même l’instanciation furtive d’une Form peut initialiser des composants non pris en charge en Session 0, entraînant des blocages et des deadlocks qui affectent d’autres processus SYSTEM.
Plan d’actions correctives — priorisé
<h3>Supprimer toute dépendance UI du service</h3>
<ul>
<li>Éliminer les références à <code>System.Windows.Forms</code> et <code>PresentationFramework</code>/<code>WPF</code>.</li>
<li>Vérifier que <code>OnStart</code> / <code>OnStop</code> n’utilisent <strong>aucune</strong> API User32/GDI/GDI+ (y compris <code>System.Drawing</code> pour des opérations d’icônes ou d’images).</li>
<li>Supprimer l’usage de <code>Microsoft.Win32.SystemEvents</code> (souvent innocent en apparence, mais déclenche la création d’une fenêtre cachée <em>SystemEvents</em>).</li>
<li>Remplacer tout affichage ou saisie par du <em>logging</em> (EventLog, ETW, fichiers journaux) et des mécanismes d’administration hors‑bande (PowerShell/CLI).</li>
</ul>
<h3>Séparer service et interface : architecture recommandée</h3>
<p>Diviser l’application en deux exécutables :</p>
<ul>
<li><strong>Service</strong> : logique de fond (<em>headless</em>), aucun composant UI, exécuté sous un compte de service dédié (gMSA ou compte virtuel).</li>
<li><strong>Application UI</strong> : lancée dans la session interactive utilisateur via une <strong>Tâche planifiée « At logon »</strong> ou via la clé <code>HKCU\Software\Microsoft\Windows\CurrentVersion\Run</code>. Elle communique avec le service par IPC.</li>
</ul>
<h4>Schéma textuel</h4>
<pre><code>Session 0 (non interactive) Session utilisateur (interactive)
┌───────────────────────────┐ ┌──────────────────────────────┐ │ Service Windows (headless)│◄──IPC───►│ UI (WinForms/WPF/WinUI) │ │ – Worker/HostedService │ │ – Tray, fenêtres, dialogues │ │ – Journalisation │ │ – Authentification interactive│ └───────────────────────────┘ └──────────────────────────────┘
<h3>Si une interaction visuelle est absolument nécessaire</h3>
<p>Le service ne doit pas héberger d’UI. <strong>Lancer un processus séparé</strong> dans la session interactive active en utilisant les API WTS (<code>WTSGetActiveConsoleSessionId</code>, <code>WTSQueryUserToken</code>) et <code>CreateProcessAsUser</code>. Ce processus dispose de sa propre boucle de messages et d’un desktop interactif, sans impliquer la Session 0.</p>
<h3>Compte d’exécution</h3>
<ul>
<li>Éviter <code>LocalSystem</code> pour la logique applicative : privilégier un compte le moins privilégié possible (gMSA recommandé sur un DC) et limiter les droits (lectures/écritures, pare‑feu, accès réseau).</li>
<li>Déclarer explicitement les droits nécessaires (services dépendants, accès dossiers, ACLs sur canaux IPC).</li>
</ul>
<h3>Validation rapide</h3>
<ul>
<li>Recompiler un service <em>headless</em> (sans référence UI). Si le gel disparaît, la cause est confirmée.</li>
<li>Tester sur un serveur membre et/ou une machine physique : utile pour isoler un effet de timing d’hyperviseur ou de durcissement propre au DC, mais la correction demeure <strong>d’éliminer l’UI du service</strong>.</li>
</ul>
Bonnes pratiques .NET modernes pour les services Windows
<h3>Modèle Worker Service</h3>
<p>Utilisez l’hôte générique .NET pour créer un service sans interface :</p>
<pre><code class="language-csharp">using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; Host.CreateDefaultBuilder(args) .UseWindowsService() // Run as a Windows Service .ConfigureServices(services => { services.AddHostedService\(); }) .Build() .Run(); public sealed class Worker : BackgroundService { private readonly ILogger\ \_logger; public Worker(ILogger\ logger) => \_logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Tick {Time}", DateTimeOffset.Now);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
Ce modèle évite toute dépendance User32/GDI et réduit drastiquement les risques de blocage à l’arrêt. « `
Lancer l’UI à la connexion utilisateur
Pour démarrer la partie UI uniquement dans une session interactive, créez une Tâche planifiée « À l’ouverture de session » :
# Exécute l'UI pour chaque utilisateur à sa connexion, avec élévation si nécessaire
```
\$action = New-ScheduledTaskAction -Execute "C:\Program Files\MonApp\MonUI.exe"
\$trigger = New-ScheduledTaskTrigger -AtLogOn
\$principal = New-ScheduledTaskPrincipal -UserId "BUILTIN\Users" -LogonType Interactive -RunLevel Highest
Register-ScheduledTask -TaskName "MonApp UI" -Action \$action -Trigger \$trigger -Principal \$principal </code></pre> <p>La communication avec le service se fait via IPC (voir ci‑dessous).</p>
</section>
<section>
<h2>Choisir un canal IPC adapté</h2>
<p>Un bon choix d’IPC garantit la robustesse et la maintenabilité. Voici un comparatif rapide :</p>
<table>
<thead>
<tr>
<th>Canal IPC</th>
<th>Usage typique</th>
<th>Avantages</th>
<th>Points d’attention</th>
</tr>
</thead>
<tbody>
<tr>
<td>Named Pipes</td>
<td>UI ↔ service sur la même machine</td>
<td>Rapide, ACL fines, natif Windows</td>
<td>Machine locale uniquement, gestion de sécurité à soigner</td>
</tr>
<tr>
<td>TCP/HTTP + gRPC</td>
<td>API binaire moderne, multi‑plateforme</td>
<td>Contrats forts, performances, streaming</td>
<td>Ouverture de port, TLS, gouvernance des versions</td>
</tr>
<tr>
<td>HTTP REST</td>
<td>Interop, outils universels</td>
<td>Simplicité, outillage riche</td>
<td>Overhead, conventions à stabiliser</td>
</tr>
<tr>
<td>WCF (legacy)</td>
<td>Environnements hérités</td>
<td>Transports multiples, bindings variés</td>
<td>Évolutivité limitée sur .NET 6+, modernisation souhaitable</td>
</tr>
<tr>
<td>Fichiers/FS</td>
<td>Échanges simples, journaux</td>
<td>Very low tech, audit facile</td>
<td>Concurrence, verrouillage, antivirus</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>Alternative avancée : lancer un processus UI depuis le service</h2>
<p>À utiliser en dernier recours. Le service détecte la session interactive active et lance un exécutable UI dans le contexte utilisateur. Schéma de haut niveau :</p>
<ol>
<li><code>WTSGetActiveConsoleSessionId</code> pour récupérer l’ID de session.</li>
<li><code>WTSQueryUserToken</code> pour obtenir un <em>primary token</em> utilisateur.</li>
<li><code>DuplicateTokenEx</code> + <code>CreateEnvironmentBlock</code> pour préparer l’environnement.</li>
<li><code>CreateProcessAsUser</code> pour démarrer l’UI (pas de boucle de messages dans le service).</li>
</ol>
<pre><code class="language-csharp">// *Extrait* simplifié, gestion d'erreurs et sécurité à compléter
uint sessionId = WTSGetActiveConsoleSessionId();
if (WTSQueryUserToken(sessionId, out IntPtr userToken))
{
var si = new STARTUPINFO();
var pi = new PROCESS_INFORMATION();
var env = IntPtr.Zero;
CreateEnvironmentBlock(out env, userToken, false);
```
try
{
CreateProcessAsUser(
userToken,
null,
"\"C:\\Program Files\\MonApp\\MonUI.exe\"",
IntPtr.Zero, IntPtr.Zero,
false,
0x00000400 | 0x00000010, // CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLE
env,
null,
ref si,
out pi);
}
finally
{
if (env != IntPtr.Zero) DestroyEnvironmentBlock(env);
CloseHandle(userToken);
if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
}
```
} </code></pre> <p><strong>Important</strong> : valider les ACLs, le contexte d’intégrité, l’héritage de handle, et tracer l’opération. Ne jamais afficher d’UI directement depuis la Session 0.</p>
</section>
<section>
<h2>Sécurité et comptes de service</h2>
<table>
<thead>
<tr>
<th>Compte</th>
<th>Cas d’usage</th>
<th>Avantages</th>
<th>Risques/Contraintes</th>
</tr>
</thead>
<tbody>
<tr>
<td>LocalSystem</td>
<td>Services OS critiques</td>
<td>Accès étendu local</td>
<td>Trop puissant pour l’applicatif, risque accru sur DC</td>
</tr>
<tr>
<td>gMSA</td>
<td>Applications serveur/domain‑joined</td>
<td>Mots de passe gérés par AD, rotation automatique</td>
<td>Nécessite AD DS opérationnel, délégations à définir</td>
</tr>
<tr>
<td>Compte de service dédié</td>
<td>Isolation stricte</td>
<td>Principe du moindre privilège</td>
<td>Gestion des secrets (si non gMSA)</td>
</tr>
<tr>
<td>Virtual Service Account</td>
<td>Services isolés locaux</td>
<td>Pas de mot de passe à gérer</td>
<td>Accès réseau limités</td>
</tr>
</tbody>
</table>
<p>Sur un contrôleur de domaine, privilégier gMSA pour éviter la gestion manuelle des secrets et limiter la surface d’attaque. Documenter précisément les privilèges requis (accès fichier, registre, pare‑feu, endpoints IPC).</p>
</section>
<section>
<h2>Diagnostic pas à pas</h2>
<h3>Approche A/B</h3>
<p>Compiler deux versions : l’une qui référence WinForms/WPF et instancie une <code>Form</code>, l’autre totalement <em>headless</em>. Redémarrer et comparer. La version <em>headless</em> valide généralement la disparition du gel.</p>
<h3>Observabilité renforcée</h3>
<ul>
<li>Activer une journalisation structurée (ID d’événements, corrélation, niveaux).</li>
<li>Ajouter des compteurs de performance ou événements ETW ciblant les phases start/stop.</li>
<li>ProcDump à l’arrêt du processus gelé : inspecter les stacks User32, ntdll, <code>SystemEvents</code> et les appels <code>SendMessage</code>/<code>WaitForSingleObject</code>.</li>
</ul>
<h3>Outils paravirtualisés</h3>
<p>Maintenir à jour les outils XCP‑ng/para‑drivers. Ils ne sont pas la cause première mais de vieux drivers peuvent amplifier des délais de signaux ou masquer les symptômes.</p>
</section>
<section>
<h2>Anti‑patrons fréquents à éviter</h2>
<ul>
<li>Afficher des <em>MessageBox</em> dans un service pour « déboguer » : interdit en Session 0.</li>
<li>Utiliser <code>System.Drawing</code> pour générer des vignettes/icônes dans le service : préférez des bibliothèques non GDI+ ou effectuez ces opérations côté UI ou en mode <em>headless</em> compatible.</li>
<li>Souscrire à <code>SystemEvents.PowerModeChanged</code> ou <code>TimeChanged</code> dans le service : provoque la création de fenêtres cachées.</li>
<li>Lancer une boucle de messages dans le service (ex. <code>Application.Run()</code>) : non supporté.</li>
</ul>
</section>
<section>
<h2>Checklist de correction</h2>
<ul>
<li>Le projet de service ne référence aucun package UI (WinForms, WPF, System.Drawing, composants tiers UI).</li>
<li>Le service démarre vite et délègue la logique à un <code>BackgroundService</code> ou un fil non‑bloquant.</li>
<li>L’UI est un exécutable distinct, déclenché à la connexion, communiquant via IPC.</li>
<li>Le compte de service est un gMSA/compte dédié à privilèges limités.</li>
<li>Des journaux et métriques couvrent <code>OnStart</code>/<code>OnStop</code> et les transitions d’état.</li>
<li>Des tests de redémarrage et d’arrêt (arrêt système, redémarrage, arrêt forcé du service) sont automatisés.</li>
</ul>
</section>
<section>
<h2>Exemple : passage d’un service UI à un service « headless »</h2>
<h3>Avant (problématique)</h3>
<pre><code class="language-csharp">protected override void OnStart(string[] args)
{
// Démarre des composants d'UI (à proscrire)
Application.EnableVisualStyles();
using var f = new Form1();
// ... initialisations diverses ...
}
Après (corrigé)
protected override void OnStart(string[] args)
{
// Déléguer la logique à un worker non UI
_ = Task.Run(() => _engine.StartAsync(_cts.Token));
}
protected override void OnStop()
{
\_cts.Cancel();
\_engine.Stop();
}
Le composant \_engine
ne doit dépendre d’aucune API UI. Toute interaction utilisateur est déplacée dans l’application UI distincte.
Scénarios spécifiques aux contrôleurs de domaine
- Attention aux GPO qui restreignent l’exécution ou l’élévation d’exécutables UI : valider la tâche planifiée At logon avec les bons contextes.
- Limiter la surface d’administration interactive côté DC : préférer l’UI sur postes d’administration et l’IPC sécurisé vers le service du DC.
- Éviter au maximum
LocalSystem
pour l’applicatif sur DC ; la séparation de privilèges est essentielle.
Foire aux questions
« Mais je n’affiche jamais la fenêtre ! Pourquoi ça bloque ? »
Parce que l’instanciation d’une Form
suffit à initialiser User32/GDI+ et des fenêtres cachées. C’est la présence de ces composants en Session 0 — non leur visibilité — qui pose problème.
« Ça marchait sur Windows Server 2003, pourquoi plus maintenant ? »
Avant l’isolation stricte de la Session 0, certains services « interactifs » profitaient d’une tolérance inexistante aujourd’hui. Le modèle moderne interdit de facto toute UI dans un service.
« XCP‑ng est‑il en cause ? »
Non : l’hyperviseur peut influer sur les timings, mais l’UI en Session 0 demeure la cause racine. La mise à jour des para‑drivers reste recommandée par bonnes pratiques.
Résumé exécutable
- Ne créez jamais d’UI dans un service Windows — même transitoire.
- Adoptez une architecture service headless + UI séparée avec IPC.
- Utilisez un compte de service à privilèges minimaux (gMSA idéalement sur un DC).
- Validez par A/B test ; instrumentez
OnStart
/OnStop
et surveillez les files d’attente. - Si une UI est incontournable, créez‑la dans la session interactive via
CreateProcessAsUser
, jamais dans la Session 0.
En appliquant ces principes, les gels à la fermeture des processus SYSTEM disparaissent, le service gagne en stabilité, et l’architecture respecte les règles de sécurité et de fiabilité de Windows Server 2025 — que ce soit sur matériel physique ou virtualisé XCP‑ng.