Empêcher les lancements multiples d'une application Windows — Mutex nommé et activation de la fenêtre existante lors d'un second lancement

· Mis à jour le: · · Prévention des lancements multiples, Mutex, Windows, .NET, C#, SetForegroundWindow, Bureau à distance, Tubes nommés, Développement Windows, Conseil technique

« J’ai accidentellement relancé l’application métier, j’ai fini par modifier le même fichier dans deux fenêtres différentes, et les changements de l’une d’elles ont disparu. » « Un outil résident a démarré en double, et le même événement a été traité deux fois. » Les lancements multiples d’une application de bureau semblent un sujet anodin, mais en pratique ils sont une source d’incidents étonnamment fréquente. Le principe de la contre-mesure est simple en lui-même : utiliser un Mutex nommé pour déterminer « suis-je la première instance ? ».

Le problème, c’est que cette implémentation en apparence simple s’accompagne de pièges annexes : le piège des espaces de noms qui désactive la protection en environnement Bureau à distance, les règles de libération propres au Mutex — différentes de celles des autres objets de synchronisation —, et la question de conception « comment mettre au premier plan l’instance existante trouvée », qui se heurte aux restrictions Win32 sur la fenêtre de premier plan. Cet article couvre l’ensemble du sujet, des bases de la détection des lancements multiples avec un Mutex nommé jusqu’aux décisions de conception annexes nécessaires en pratique.

1. L’essentiel d’abord

  • La forme de base de la détection des lancements multiples est new Mutex(true, name, out bool createdNew). Aucune exception n’est levée même si un Mutex du même nom existe déjà : createdNew vaut simplement false, et c’est sur cette valeur qu’il faut brancher.1
  • Si vous ne préfixez pas le nom, il est créé par défaut dans l’espace de noms Local\ (limité à la session). Dans un environnement Bureau à distance où le même utilisateur possède plusieurs sessions, chaque session est traitée comme ayant son propre Mutex distinct, et la prévention des lancements multiples ne fonctionne pas. Si vous voulez réduire cela à une seule instance à travers les sessions, le préfixe Global\ est indispensable.23
  • Un Mutex ne peut être libéré que par le thread qui l’a acquis. Appeler ReleaseMutex depuis un autre thread déclenche une ApplicationException. Si le processus propriétaire se termine sans le libérer, le prochain thread qui l’acquiert reçoit une AbandonedMutexException — un signal indiquant que l’attente elle-même a réussi. La bonne pratique consiste à ne pas l’ignorer, mais à vérifier la cohérence de l’état avant de l’utiliser.45
  • Une fois établi qu’« une instance est déjà lancée », l’enjeu principal est de mettre au premier plan la fenêtre de l’instance existante. L’OS restreint les appels à SetForegroundWindow en dehors du processus de premier plan, si bien qu’un simple appel échoue et se limite à faire clignoter le bouton de la barre des tâches.6 La pratique établie consiste à faire céder, via AllowSetForegroundWindow, le « droit de définir le premier plan » que détient le second processus qui vient d’être lancé, à l’instance existante.7
  • Pour transmettre les arguments de lancement (comme le chemin du fichier à ouvrir) à l’instance existante, la pratique établie consiste à les transférer via un tube nommé. Nous laissons la comparaison des différents moyens à « Comment choisir la communication inter-processus sous Windows », et nous concentrons cet article sur la conception « détection → notification → activation ».
  • Les applications console et les services ne sont pas des cibles directes pour transposer telle quelle la conception de cet article. Le cas des services est différent, car le SCM ne démarre de toute façon jamais deux services du même nom (chapitre 8).

2. Les bases de la détection avec un Mutex nommé

Un Mutex est un objet noyau : si vous le créez avec un nom, tout autre processus qui connaît ce nom peut référencer le même objet. La détection des lancements multiples n’exploite que ce « partage de nom ».

using var mutex = new Mutex(initiallyOwned: true, name: MutexName, out bool createdNew);
if (!createdNew)
{
    // Déjà lancée
    return;
}
// Ce thread ne possède le Mutex que lorsque createdNew vaut true

Deux points sont à retenir ici.

  • Aucune exception n’est levée même si un Mutex du même nom existe déjà. createdNew vaut simplement false, et une référence vers l’objet existant est retournée. Branchez toujours sur createdNew.1
  • initiallyOwned: true ne prend effet que lorsque la création a réellement pu avoir lieu. Si le Mutex existait déjà (createdNew == false), ce thread n’en devient pas automatiquement propriétaire. Cet effet de bord n’est pas problématique pour la détection des lancements multiples, mais pour éviter l’accident où « le second processus appelle ReleaseMutex par erreur », il est plus sûr, dans la branche où createdNew vaut false, de ne toucher au Mutex sous aucun prétexte et de terminer le traitement immédiatement.1

La pratique établie pour le nom consiste à y intégrer un GUID propre au produit, afin d’éviter toute collision avec l’application d’un autre éditeur (par exemple "KomuraSoft.MyApp.SingleInstance.{3F1E2B10-...}"). Tout comme pour l’occupation (squattage) de noms de tubes, l’espace de noms des objets noyau nommés est visible par les autres processus de la machine : il est donc utile de choisir un nom difficile à deviner.

3. Global\ et Local\ — ce qui se passe à travers les sessions

Les espaces de noms des objets noyau Windows se répartissent entre des espaces de noms indépendants par session et un espace de noms global partagé par l’ensemble du système. Lorsque vous créez un Mutex nommé sans spécifier de préfixe, il est créé par défaut dans l’espace de noms de session de l’appelant (Local\) ; ce n’est qu’en ajoutant Global\ qu’il est créé dans l’espace de noms commun à toutes les sessions.3 La classe Mutex de .NET reprend ce comportement tel quel, et sa documentation précise explicitement qu’« un Mutex nommé sans préfixe a par défaut Local\ ».2

Cela devient problématique dans des situations comme les suivantes.

  • Un utilisateur connecté directement depuis la console à un poste d’administration métier ouvre en plus, pour lui-même, une seconde session via le Bureau à distance (une situation fréquente en exploitation).
  • Le même utilisateur déconnecte et reconnecte à répétition sa session RDP, et une nouvelle ID de session lui est attribuée à chaque fois.

Si vous créez le Mutex en le laissant en Local\ (sans préfixe), ces cas sont traités comme des sessions distinctes, et la prévention des lancements multiples fonctionne indépendamment dans chaque session. Autrement dit, vous obtenez le bug suivant : « bien qu’il s’agisse du même utilisateur, une seconde session peut lancer l’application normalement » — la prévention des lancements multiples ne fonctionne pas comme prévu. À l’inverse, pour un usage de bureau classique qui ne suppose qu’une seule session, conserver Local\ (la valeur par défaut) ne cause aucun dommage réel.

La conception générale des postes partagés par plusieurs utilisateurs est aussi abordée dans « Introduction aux profils utilisateur Windows », mais en se concentrant sur le sujet de cet article, une précaution s’impose. Global\ est un espace de noms unique partagé par tous les utilisateurs et toutes les sessions de la machine ; il ne signifie pas automatiquement « une instance par utilisateur ». Si vous vous contentez d’ajouter Global\ en gardant un nom fixe comme Global\KomuraSoft.MyApp.SingleInstance, alors pendant que l’utilisateur A a l’application ouverte, le lancement de l’utilisateur B se heurte lui aussi au même Mutex — vous obtenez de fait « une instance pour toute la machine » (troisième ligne du tableau du chapitre 5). Si vous voulez réaliser « une instance par utilisateur, mais en regroupant les sessions multiples de cet utilisateur », vous devez, en plus de Global\, intégrer dans le nom un identifiant propre à l’utilisateur (comme son SID). Inversement, si vous souhaitez réellement réduire à une instance pour toute la machine (le partage entre tous les utilisateurs étant l’intention), conserver Global\ sans SID convient parfaitement.

4. Les règles de libération du Mutex — thread propriétaire et AbandonedMutexException

Le Mutex possède une contrainte que n’ont pas d’autres objets de synchronisation comme Semaphore ou AutoResetEvent : une règle qui impose l’ID du thread, selon laquelle il ne peut être libéré que par le thread même qui l’a acquis.2 Appeler ReleaseMutex depuis un autre thread déclenche une ApplicationException (« le thread appelant ne possède pas le mutex »).4

Dans le code .NET utilisant async/await, le traitement peut reprendre sur un thread de pool différent après un await. Attention : écrire un code qui acquiert un Mutex, insère un traitement asynchrone juste après, puis le libère dans la suite peut violer silencieusement cette contrainte. Pour la détection des lancements multiples, il est plus sûr de garder l’acquisition et la libération à l’intérieur d’un code synchrone et court.

Un autre point de vigilance est l’abandon (abandoned). Si le thread propriétaire d’un Mutex se termine sans appeler ReleaseMutex (le processus plante, s’arrête sur une exception non gérée, etc.), ce Mutex passe à l’état abandonné. Le prochain thread qui acquiert ce Mutex reçoit alors une AbandonedMutexException, mais cette exception indique en réalité que l’attente elle-même a réussi et que l’appelant a déjà obtenu la propriété du Mutex.5 Cela ne se produit normalement pas dans un usage limité à la détection des lancements multiples (puisque le Mutex est acquis et conservé sans être libéré jusqu’à la fin du processus), mais si vous réutilisez le même Mutex pour d’autres besoins d’exclusion mutuelle, traitez-le comme suit, en gardant à l’esprit que l’état protégé pourrait être corrompu.

try
{
    if (mutex.WaitOne(TimeSpan.FromSeconds(5)))
    {
        // Traitement normal
    }
}
catch (AbandonedMutexException)
{
    // L'attente a réussi, et ce thread possède déjà la propriété.
    // Inspectez l'état protégé avant de l'utiliser, ou réinitialisez-le en toute sécurité
}

Le choix entre « ignorer l’exception », « vérifier l’état et continuer » ou « abandonner et se terminer anormalement » relève exactement de la logique exposée dans « Tableau de décision : terminer ou continuer face à une exception imprévue » sur ce blog. AbandonedMutexException est une exception qui indique explicitement « la portée susceptible d’être corrompue » ; la ligne de conduite de base est donc de ne pas l’ignorer, et de se contenter d’inspecter cette portée (les données protégées).

Par ailleurs, sur le chemin de terminaison normale, vous devriez appeler explicitement ReleaseMutex dans un finally. Lorsque le thread propriétaire disparaît avec la fin du processus, le Mutex est traité comme « abandonné » ; ne pas le libérer explicitement provoquera donc une AbandonedMutexException inutile au prochain démarrage.

5. Tableau de décision — à quelle portée utiliser le Mutex

La conception de l’espace de noms et du côté tube dépend de l’« unité » à laquelle vous souhaitez empêcher les lancements multiples.

Unité Scénario envisagé Espace de noms du Mutex Transmission des arguments de lancement Points de vigilance
Par session (par défaut) Usage de bureau ordinaire sans RDP, ou lorsque « une instance par session » suffit Sans préfixe (= Local\) CurrentUserOnly, plus inclure l’ID de session dans le nom du tube Ne peut pas empêcher un lancement depuis une autre session en environnement combinant RDP. Si le même utilisateur possède plusieurs sessions, un nom de tube fixe sans l’ID de session provoque une collision (les tubes nommés ne relèvent pas de l’espace de noms de session du Mutex)
Par utilisateur (à travers les sessions) Le même utilisateur va et vient entre la console et le RDP, ou reconnecte sa session RDP à répétition Global\ + intégrer le SID de l’utilisateur dans le nom + expliciter l’ACL via MutexSecurity CurrentUserOnly (le jugement se fait sur le SID de l’utilisateur, ce qui fonctionne donc à travers les sessions) Le cas le plus souvent requis en pratique. Se contenter de Global\ sans SID produit involontairement un comportement à l’échelle de la machine (ligne suivante). Le SID n’étant pas une information secrète de l’utilisateur, sur un PC partagé / en environnement RDS, il faut aussi prévoir le squattage de nom par un autre utilisateur et s’en protéger via une ACL
Par machine (à travers tous les utilisateurs) La licence limite à une instance par machine, ou une ressource partagée doit être exclusive pour tous les utilisateurs Global\ (sans information propre à l’utilisateur) + expliciter l’ACL via MutexSecurity Retirer CurrentUserOnly, et expliciter les utilisateurs autorisés via PipeSecurity Dans un environnement Remote Desktop Services où plusieurs utilisateurs sont connectés simultanément, cela tend à produire un comportement indésirable du point de vue métier ; vérifiez donc que cela correspond bien aux exigences. Ne transférez jamais tels quels les arguments de lancement d’un autre utilisateur vers la fenêtre du premier utilisateur. Cela peut divulguer un chemin de fichier ou ouvrir le document d’une autre personne dans la session d’un utilisateur non prévu — refusez les requêtes provenant d’autres utilisateurs, ou repensez la conception autour d’un broker sans UI

Précision : contrairement au Mutex, les tubes nommés ne relèvent pas de l’espace de noms de session Local\/Global\, et sont par défaut joignables à travers les sessions. Autrement dit, même sans CurrentUserOnly, tant que le nom du tube correspond, la connexion elle-même parvient à une instance existante d’une autre session. Ici, CurrentUserOnly ne concerne pas l’accessibilité mais l’autorisation : c’est un contrôle d’accès qui restreint « qui est autorisé à se connecter » à l’utilisateur courant (et au même niveau d’élévation). Sans lui, le descripteur de sécurité par défaut accorde aussi l’accès en lecture à Everyone, si bien que des connexions provenant d’autres utilisateurs non prévus parviendraient aussi à passer. Dans une conception qui réalise « par utilisateur, à travers les sessions » avec un Mutex Global\ + SID, il convient d’ajouter aussi CurrentUserOnly au tube de notification, afin de restreindre l’origine des connexions au même « seul utilisateur ciblé » que le Mutex. Heureusement, PipeOptions.CurrentUserOnly se juge sur le SID de l’utilisateur (et le niveau d’élévation) plutôt que sur l’ID de session, ce qui permet de le combiner directement avec la deuxième ligne du tableau ci-dessus (par utilisateur, à travers les sessions).

Autre point : si vous utilisez, pour l’unité machine (troisième ligne du tableau), un Mutex Global\ à nom fixe, faites aussi attention à l’occupation de nom (squattage). Si vous tentez de restreindre une application à une instance unique à l’aide d’un Mutex nommé, un utilisateur malveillant peut créer par avance un Mutex du même nom et perturber le lancement de l’application.8 Expliciter une ACL à la création via MutexSecurity (MutexAcl.Create) permet, si vous avez pu le créer en premier, d’empêcher un autre utilisateur de détourner ce Mutex ou de le conserver indûment. Notez toutefois que cette mesure protège contre le fait d’« être gêné après coup », mais n’empêche pas en soi d’« être occupé en premier ». L’ACL ne s’applique qu’au moment où vous créez le Mutex pour la première fois ; si un utilisateur malveillant a déjà créé un Mutex du même nom avant vous, votre appel (quelle que soit l’ACL préparée) se contentera d’ouvrir l’objet existant que cette personne a configuré, et vous serez soumis à son ACL. Le fait que le nom lui-même soit difficile à deviner a une valeur en soi, mais si vous voulez exclure totalement un utilisateur malveillant disposant du droit d’exécuter du code local, ne comptez pas uniquement sur l’occupation du nom du Mutex : envisagez de la combiner avec un autre mécanisme d’exclusion, comme un fichier de verrouillage situé dans un répertoire protégé par utilisateur.9

Encore un point : CurrentUserOnly comporte une contrainte facile à négliger. Sous Windows, une connexion n’est autorisée que si, en plus du compte utilisateur, le niveau d’élévation (exécution en tant qu’administrateur ou non) correspond également.10 On a tendance à penser que « puisque le nom du Mutex n’est construit qu’à partir du SID de l’utilisateur, la détection elle-même fonctionne indépendamment de l’élévation » — mais lorsque vous utilisez un Mutex avec une ACL explicite, l’élévation entre aussi en jeu ici. Par défaut, Windows attribue une étiquette de niveau d’intégrité élevé aux objets créés par un processus s’exécutant à un niveau d’intégrité élevé (en tant qu’administrateur), et refuse les accès en écriture provenant de processus de niveau d’intégrité inférieur. Les API Mutex/MutexAcl.Create de .NET demandent en interne, en plus de SYNCHRONIZE et MUTEX_MODIFY_STATE, également DELETE/READ_CONTROL/WRITE_DAC/WRITE_OWNER (STANDARD_RIGHTS_REQUIRED) ; ainsi, si vous lancez la première instance « en tant qu’administrateur » puis une seconde avec des privilèges normaux, l’appel de création/ouverture du Mutex lui-même peut déclencher une UnauthorizedAccessException. Autrement dit, la prémisse du chapitre 7, selon laquelle « seul le tube de notification est rejeté par l’ACL, tandis que la prévention des lancements multiples elle-même réussit », peut ne plus tenir, et une exception peut être levée bien avant même la détection. Traitez également ce cas comme un signal indiquant qu’« une autre instance est déjà en cours d’exécution, ou que le niveau d’élévation diffère au point de rendre la détection impossible par les moyens habituels » : il convient d’entourer l’appel à Mutex/MutexAcl.Create lui-même d’un try/catch (UnauthorizedAccessException), et, en cas d’exception, de renoncer au lancement et de se terminer silencieusement (ou, comme pour « la notification peut ne pas arriver » au chapitre 7, de pencher du côté sûr pour la prévention des lancements multiples). Si vous avez besoin d’une notification qui franchisse aussi les niveaux d’élévation, abandonnez CurrentUserOnly au profit d’une conception qui construit explicitement une ACL basée sur le SID de l’utilisateur via PipeSecurity.

6. Mettre au premier plan l’instance existante — les restrictions de SetForegroundWindow

Une fois qu’on sait, via le Mutex, qu’« une instance est déjà lancée », la plupart des applications souhaitent mettre au premier plan la fenêtre de l’instance existante. Or, un simple appel à SetForegroundWindow sur le processus existant échoue le plus souvent.

Windows restreint strictement quel processus peut définir la fenêtre de premier plan. D’après la documentation officielle, à moins que le processus appelant ne remplisse l’une des conditions suivantes, SetForegroundWindow ne met pas réellement la fenêtre au premier plan et se contente de faire clignoter le bouton de la barre des tâches.6

  • Le processus appelant est lui-même le processus de premier plan actuel
  • Le processus appelant a été lancé par le processus de premier plan
  • Le processus appelant a reçu le dernier événement d’entrée en date
  • Il n’existe actuellement aucune fenêtre de premier plan
  • Le processus de premier plan ou le processus appelant est en cours de débogage

Dans le scénario de la détection des lancements multiples, l’instance existante (le premier processus, qui s’exécute en arrière-plan) ne remplit généralement aucune de ces conditions. En revanche, le second processus que l’utilisateur vient de lancer en double-cliquant vient souvent tout juste de recevoir un événement d’entrée, et détient donc le droit de définir le premier plan. La pratique établie en production consiste à exploiter cette asymétrie.

AllowSetForegroundWindow est une API qui permet à un processus disposant du droit de définir le premier plan de céder ce droit à un autre processus.7 Si le second processus cède son propre droit avec ASFW_ANY (l’accordant à tous les processus), puis demande l’activation à l’instance existante via un tube nommé, l’appel à SetForegroundWindow du côté de l’instance existante réussira.

[DllImport("user32.dll")]
private static extern bool AllowSetForegroundWindow(int dwProcessId);

private const int ASFW_ANY = -1;

// En supposant que le second processus (nous-mêmes) détient le droit de définir
// le premier plan, on le cède sans condition. Cela permettra à l'appel
// SetForegroundWindow du côté de l'instance existante de réussir
AllowSetForegroundWindow(ASFW_ANY);

Il reste des cas que même cela ne peut pas sauver (lancement via le Planificateur de tâches, par exemple, où le second processus lui-même n’a pas non plus le droit de premier plan). Dans ce cas, une conception raisonnable consiste à se limiter à une notification par clignotement dans la barre des tâches, sans forcer la mise au premier plan. Pour la notification à l’utilisateur, il est judicieux de toujours recourir à une notification toast et de ramener systématiquement le WindowState de la fenêtre de Minimized à Normal, tout en considérant la mise au premier plan finale comme une opération « à faire si possible » plutôt qu’une obligation — cela évite tout dysfonctionnement.

7. Transmettre les arguments de lancement à l’instance existante — les tubes nommés

Au-delà de la simple détection des lancements multiples, il est aussi fréquent d’avoir l’exigence suivante : « si l’application est lancée avec un fichier à ouvrir en argument, l’instance existante doit ouvrir ce fichier ». WM_COPYDATA (le moyen classique d’envoyer des données via un message de fenêtre) existe aussi comme option pour cet usage, mais le gain obtenu ne compense guère la lourdeur de la récupération du handle de fenêtre et du marshalling du message ; pour une nouvelle conception, il est plus naturel d’utiliser un tube nommé.

La conception est simple : le second processus, qui a déterminé via le Mutex qu’« une instance est déjà lancée », se connecte au tube nommé sur lequel l’instance existante est en attente, et lui envoie les arguments de ligne de commande sérialisés, par exemple en JSON. Les points de vigilance propres à l’implémentation des tubes nommés eux-mêmes — contre-mesures contre l’occupation (squattage) de nom de tube, contrôle d’accès via CurrentUserOnly, etc. — sont regroupés dans la section consacrée aux tubes nommés de « Comment choisir la communication inter-processus sous Windows » : reportez-vous-y. Le seul point de vigilance propre au contexte de la détection des lancements multiples est le suivant.

  • Même si l’envoi de la notification échoue, on peut considérer que la prévention des lancements multiples a réussi. Il peut arriver, pour des raisons de timing, que la notification n’arrive pas — par exemple si l’instance existante était en cours de fermeture et avait déjà fermé son serveur de tube. Même dans ce cas, l’objectif principal — « ne pas laisser le second processus se lancer » — est atteint ; il n’est donc pas nécessaire d’afficher une boîte de dialogue d’erreur au seul motif que la notification a échoué.

8. Différences avec les applications console et les services

La conception présentée dans cet article suppose une application de bureau dotée d’une fenêtre. Pour les applications console et les outils par lots, il est souvent plus réaliste de concevoir la « coexistence sûre en cas d’exécutions multiples » plutôt que « d’empêcher les lancements multiples » — ce qui revient en pratique à une question de contrôle d’exclusion mutuelle pour l’intégration par fichiers.

Les services Windows constituent un cas encore différent. Le Gestionnaire de contrôle des services (SCM) ne démarre de toute façon jamais simultanément deux services portant le même nom, si bien que la détection par Mutex présentée dans cet article n’est fondamentalement pas nécessaire. Dans une configuration de type « application UI + service résident », on n’utilise la conception de cet article que du côté UI ; la manière de raisonner sur les lancements/exécutions multiples du côté service relève d’un autre sujet. Nous laissons la façon de construire un service et ses points de conception clés à un autre article.

9. Exemple d’implémentation — détection et demande d’activation via Mutex

Voici une configuration minimale et pratique qui rassemble le contenu des chapitres 2 à 7. Elle suppose WPF, mais fonctionne presque telle quelle avec WinForms, en remplaçant simplement Application.Current.Dispatcher par Control.Invoke.

using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;

public static class Program
{
    // Intègre un GUID propre au produit pour éviter toute collision de nom avec une autre application.
    // Comme on veut restreindre à une instance « par utilisateur, à travers les sessions », on intègre
    // le SID de l'utilisateur dans le nom en plus de Global\. Si on se contente de Global\ sans SID,
    // tous les utilisateurs finissent par partager le même Mutex, ce qui devient « une instance pour toute la machine » (chapitre 3)
    private static readonly string MutexName =
        $@"Global\KomuraSoft.MyApp.SingleInstance.{{3F1E2B10-9C2E-4B7E-8B1E-8B4F2D6A5C10}}.{WindowsIdentity.GetCurrent().User}";
    // Aligne aussi le tube de notification sur la même portée (par utilisateur) que le Mutex. Avec un nom
    // fixe sans SID, une collision ou un mélange peut se produire si un autre utilisateur monte
    // un serveur sous le même nom (chapitre 5 ; CurrentUserOnly ne fait que restreindre l'ACL, il ne sépare pas les noms)
    private static readonly string PipeName =
        $"KomuraSoft.MyApp.Activate.{{3F1E2B10-9C2E-4B7E-8B1E-8B4F2D6A5C10}}.{WindowsIdentity.GetCurrent().User}";

    [STAThread]
    private static void Main(string[] args)
    {
        // Expliciter une ACL n'accordant le contrôle total qu'à l'utilisateur courant permet,
        // si l'on a pu créer l'objet en premier, d'empêcher un détournement ou une gêne
        // de la part d'un autre utilisateur (nécessite le paquet NuGet System.Threading.AccessControl).
        // Cela ne protège toutefois pas contre le fait d'« être occupé en premier » (chapitre 5)
        var mutexSecurity = new MutexSecurity();
        mutexSecurity.AddAccessRule(new MutexAccessRule(
            WindowsIdentity.GetCurrent().User!, MutexRights.FullControl, AccessControlType.Allow));

        bool createdNew;
        Mutex mutex;
        try
        {
            // Ce n'est que lorsque createdNew vaut true que cet appel a réellement acquis le Mutex
            mutex = MutexAcl.Create(
                initiallyOwned: true, name: MutexName, createdNew: out createdNew, mutexSecurity: mutexSecurity);
        }
        catch (UnauthorizedAccessException)
        {
            // Même pour un même utilisateur, un niveau d'élévation différent peut faire refuser
            // l'ouverture même du Mutex existant (chapitre 5). On considère alors qu'« une autre
            // instance existe déjà à un niveau d'élévation différent », on renonce à la notification
            // et on penche du côté sûr (ne pas se lancer)
            return;
        }
        using var _ = mutex;

        if (!createdNew)
        {
            // Déjà lancée. Notifier l'instance existante et se terminer soi-même
            NotifyRunningInstanceAsync(args).GetAwaiter().GetResult();
            return;
        }

        try
        {
            // Veillez à supprimer le StartupUri de App.xaml. Si vous le laissez, la fenêtre issue du
            // StartupUri sera aussi auto-générée et affichée en plus de la MainWindow créée manuellement
            // ici (app.MainWindow sera d'ailleurs écrasé par cette dernière), ce qui aboutit à deux fenêtres
            // ouvertes, avec le risque que la demande d'activation atterrisse sur la mauvaise
            var app = new App();
            app.InitializeComponent();
            var mainWindow = new MainWindow();
            app.MainWindow = mainWindow;
            mainWindow.Show();

            // Démarre le serveur de tube seulement après que la création et l'affichage de MainWindow
            // sont terminés. Dans l'ordre inverse, une demande d'activation pourrait arriver alors que
            // la fenêtre n'existe pas encore, ce qui peut faire échouer l'appel à ActivateMainWindow
            // (l'exception est alors avalée par le catch-all ci-dessous, et la notification disparaît
            // simplement). Une requête arrivant pendant cet intervalle est traitée comme « serveur pas
            // encore démarré → échec de connexion », en s'appuyant tel quel sur la conception au mieux
            // (best-effort) du côté NotifyRunningInstanceAsync (chapitre 7)
            StartActivationServer();

            app.Run();
        }
        finally
        {
            // Libère explicitement depuis le thread propriétaire (ce thread) avant de se terminer.
            // Se terminer sans libérer provoquerait une AbandonedMutexException au prochain
            // démarrage (chapitre 4)
            mutex.ReleaseMutex();
        }
    }

    private const int ASFW_ANY = -1;

    [DllImport("user32.dll")]
    private static extern bool AllowSetForegroundWindow(int dwProcessId);

    private static async Task NotifyRunningInstanceAsync(string[] args)
    {
        try
        {
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
            using var pipe = new NamedPipeClientStream(
                ".", PipeName, PipeDirection.Out, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
            await pipe.ConnectAsync(cts.Token);

            // Nous (le second processus qui vient d'être lancé) venons souvent tout juste de recevoir
            // un événement d'entrée, et détenons donc en général le droit de définir le premier plan.
            // On cède ce droit sans condition, pour faire réussir le SetForegroundWindow côté
            // instance existante (chapitre 6)
            AllowSetForegroundWindow(ASFW_ANY);

            byte[] payload = JsonSerializer.SerializeToUtf8Bytes(new ActivateRequest(1, args));
            await pipe.WriteAsync(payload, cts.Token);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or OperationCanceledException)
        {
            // Sans distinguer la raison pour laquelle la notification n'est pas parvenue — l'instance
            // existante n'a pas répondu car elle était en cours de fermeture (IOException), ou
            // l'autorisation CurrentUserOnly a échoué en raison d'un niveau d'élévation différent
            // (UnauthorizedAccessException, voir le complément du chapitre 5) — l'objectif principal
            // de la prévention des lancements multiples (ne pas laisser le second se lancer) est
            // atteint (chapitre 7)
        }
    }

    private static void StartActivationServer()
    {
        // Limite pour un seul message. Protège la mémoire du serveur même si un ancien assistant du
        // même utilisateur, ou un client défaillant, continue d'envoyer sans limite
        const int MaxPayloadBytes = 64 * 1024;

        _ = Task.Run(async () =>
        {
            while (true)
            {
                try
                {
                    // Place aussi le constructeur lui-même dans le try. maxNumberOfServerInstances
                    // valant 1, cet appel peut lever une IOException, par exemple au moment où le
                    // nettoyage de la connexion précédente n'est pas encore terminé ; le placer en
                    // dehors du try ferait s'arrêter toute la tâche d'arrière-plan à cause d'un seul échec
                    using var pipe = new NamedPipeServerStream(
                        PipeName, PipeDirection.In, 1,
                        PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

                    await pipe.WaitForConnectionAsync();

                    // maxNumberOfServerInstances valant 1, si cette connexion reste bloquée sur un
                    // client qui ne répond pas, plus aucune demande de lancement légitime ne pourra
                    // être acceptée par la suite. On impose donc une durée maximale par connexion
                    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
                    using var ms = new MemoryStream();
                    var buffer = new byte[4096];
                    int n;
                    while ((n = await pipe.ReadAsync(buffer, cts.Token)) > 0)
                    {
                        ms.Write(buffer, 0, n);
                        if (ms.Length > MaxPayloadBytes)
                            throw new IOException("La charge utile dépasse la taille maximale autorisée.");
                    }

                    var req = JsonSerializer.Deserialize<ActivateRequest>(ms.ToArray());
                    // Valide Args en plus de Version. Un expéditeur d'une ancienne version, ou une
                    // charge utile malformée fabriquée à la main, peut envoyer un JSON du type
                    // {"Version":1} sans Args, qui se désérialise alors avec Args resté null
                    if (req is { Version: 1, Args: not null })
                    {
                        // Effectue les opérations d'UI en revenant sur le thread UI
                        Application.Current.Dispatcher.Invoke(() => ActivateMainWindow(req.Args));
                    }
                }
                catch (Exception)
                {
                    // Quelle qu'en soit la raison — connexion coupée, dépassement du temps ou de la
                    // taille maximale, charge utile corrompue, exception imprévue pendant le dispatch,
                    // etc. — on avale cela comme une anomalie limitée à cette seule connexion. Laisser
                    // mourir cette tâche elle-même empêcherait d'accepter toute notification par la
                    // suite ; la boucle doit donc toujours continuer. Cela dit, pour éviter un
                    // hot-spin dans le cas où la construction même du tube échoue immédiatement et en
                    // continu avant l'await (par exemple si un autre processus détient déjà le
                    // créneau d'instance unique), on marque toujours une pause avant la prochaine
                    // itération de la boucle
                    await Task.Delay(TimeSpan.FromSeconds(1));
                }
            }
        });
    }

    [DllImport("user32.dll")]
    private static extern bool SetForegroundWindow(IntPtr hWnd);

    private static void ActivateMainWindow(string[] args)
    {
        var window = Application.Current.MainWindow;
        if (window is null) return;

        if (window.WindowState == System.Windows.WindowState.Minimized)
            window.WindowState = System.Windows.WindowState.Normal;
        window.Show();
        window.Activate();

        // Activate() de WPF appelle SetForegroundWindow en interne, mais cela peut échouer à cause
        // de la restriction (chapitre 6) ; on l'appelle donc aussi explicitement, une fois
        // AllowSetForegroundWindow déjà effectué
        var hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle;
        SetForegroundWindow(hwnd);

        if (args.Length > 0)
        {
            // Traite args[0] comme le chemin du fichier à ouvrir, ou autre traitement spécifique à l'application
        }
    }

    private sealed record ActivateRequest(int Version, string[] Args);
}

Trois décisions de conception méritent un complément d’explication.

  • Choisissez, comme identifiant utilisateur ajouté à Global\, quelque chose dont le nom ne change pas. Le SecurityIdentifier renvoyé par WindowsIdentity.GetCurrent().User, contrairement à un nom d’utilisateur, n’est pas affecté par un renommage, et sa méthode ToString() produit une chaîne au format S-1-5-21-....11 Intégrer directement le nom d’utilisateur peut entraîner l’accident où la prévention des lancements multiples cesse de fonctionner après un changement de nom de compte ou une migration de domaine.
  • Effectuez explicitement la libération du Mutex et l’arrêt du serveur de tube dans le cadre du processus de fermeture de l’application. L’exemple ci-dessus appelle ReleaseMutex dans un finally, mais dans une véritable application, veillez aussi à arrêter la boucle du serveur de tube à l’aide d’un jeton d’annulation dans le traitement de fermeture de la fenêtre.
  • Incluez un champ version dès le départ. Il est tout à fait possible que le format des arguments de lancement change à l’avenir. Intégrer dès le début un contrôle du type « ignorer les versions inconnues » permet un comportement sûr même sur un poste où subsiste une ancienne version de l’exécutable.

10. Résumé

La prévention des lancements multiples d’une application Windows paraît simple si l’on ne regarde que les quelques lignes de new Mutex(true, name, out createdNew). Mais pour la faire fonctionner en production sans incident, il faut maîtriser un ensemble de connaissances annexes : la différence de visibilité entre sessions selon Global\/Local\, la contrainte de propriété par thread propre au Mutex et le traitement d’AbandonedMutexException, ainsi qu’une conception de l’activation tenant compte des restrictions de SetForegroundWindow.

Côté ordre d’implémentation, commencez par décider « à quelle unité (session, utilisateur ou machine) vous voulez empêcher les lancements multiples » (chapitre 5), puis alignez en conséquence l’espace de noms du Mutex et la portée du tube de notification. Ensuite, utilisez la pratique établie consistant à déléguer le droit via AllowSetForegroundWindow pour mettre l’instance existante au premier plan, et adoptez la position claire selon laquelle, dans les cas où cela échoue malgré tout, on se limite à une notification plutôt que de forcer la mise au premier plan : avec cette approche, l’implémentation ne s’effondrera pas. Si vous hésitez sur l’exigence à retenir — « une instance par utilisateur » ou « une instance pour toute la machine » —, nous recommandons de d’abord vérifier l’environnement d’exploitation (combinaison ou non avec du RDP, connexion simultanée ou non de plusieurs utilisateurs).

Articles connexes

Domaines de conseil associés

KomuraSoft LLC prend en charge la conception et l’implémentation d’applications de bureau Windows — y compris la prévention des lancements multiples et le contrôle des fenêtres —, la recherche des causes de dysfonctionnements propres aux environnements Bureau à distance, ainsi que les revues de conception d’applications existantes.

Références

  1. Microsoft Learn, Mutex Constructor. Sur le fait que si un Mutex nommé existe déjà, createdNew vaut false sans qu’aucune exception ne soit levée, et que la propriété initiale via initiallyOwned ne prend effet que lorsque createdNew vaut true.  2 3

  2. Microsoft Learn, Mutex Class. Sur le fait qu’un Mutex nommé sans préfixe spécifié a par défaut Local\, sur la différence de visibilité entre sessions Terminal Services selon Global\/Local\, et sur le fait que le Mutex impose la propriété par thread (contrairement aux autres objets de synchronisation).  2 3

  3. Microsoft Learn, Kernel Object Namespaces. Sur la structure des espaces de noms indépendants par session et de l’espace de noms global, et sur la manière de spécifier l’espace de noms via les préfixes Global\/Local.  2

  4. Microsoft Learn, Mutex.ReleaseMutex Method. Sur le fait qu’un thread qui n’en est pas propriétaire déclenche une ApplicationException en appelant ReleaseMutex, et que le Mutex passe à l’état abandonné si le thread se termine sans le libérer.  2

  5. Microsoft Learn, AbandonedMutexException Class. Sur le fait qu’une AbandonedMutexException est levée pour le prochain thread qui acquiert un Mutex abandonné, et que cela indique que l’attente elle-même a réussi et que l’appelant a obtenu la propriété du Mutex.  2

  6. Microsoft Learn, SetForegroundWindow function. Sur les conditions qu’un processus doit remplir pour définir la fenêtre de premier plan, et sur le fait que le non-respect de ces conditions se limite à faire clignoter le bouton de la barre des tâches.  2

  7. Microsoft Learn, AllowSetForegroundWindow function. Sur le fait qu’un processus pouvant définir la fenêtre de premier plan peut céder ce droit à un autre processus, et que spécifier ASFW_ANY permet de l’accorder à n’importe quel processus.  2

  8. Microsoft Learn, CreateMutexW function (synchapi.h). Sur le fait que, lorsqu’on restreint à une instance unique avec un Mutex nommé, un utilisateur malveillant peut créer en premier un Mutex du même nom et perturber le lancement de l’application, ainsi que sur des alternatives comme un nom aléatoire ou, pour une instance par utilisateur, un fichier de verrouillage sous le profil utilisateur. 

  9. Microsoft Learn, Mutexes. Sur le fait que les Mutex système nommés sont visibles dans tout l’OS et globaux, ce pour quoi il est recommandé de les protéger par un contrôle d’accès dès leur création, et sur le contrôle d’accès via MutexSecurity. 

  10. Microsoft Learn, PipeOptions Enum. Sur le fait que, sous Windows, CurrentUserOnly vérifie aussi le niveau d’élévation en plus du compte utilisateur. 

  11. Microsoft Learn, WindowsIdentity.User Property. Sur le fait qu’il s’agit d’une propriété renvoyant l’identifiant de sécurité (SID) de l’utilisateur, et que le SID identifie de façon unique un utilisateur ou un groupe dans toutes les implémentations Windows NT. 

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Comment empêcher le lancement multiple d'une application en C# ?
La forme de base consiste à appeler new Mutex(true, name, out bool createdNew). Aucune exception n'est levée même si un Mutex du même nom existe déjà : createdNew vaut simplement false, et c'est sur cette valeur qu'il faut brancher pour terminer le second processus. Il est de bonne pratique d'inclure dans le nom un GUID propre au produit, afin d'éviter toute collision avec l'application d'un autre éditeur. Dans la branche où createdNew vaut false, il est plus sûr de ne toucher au Mutex sous aucun prétexte et de terminer le traitement immédiatement.
Pourquoi la prévention des lancements multiples ne fonctionne-t-elle pas en environnement Bureau à distance ?
Parce que si vous ne préfixez pas le nom du Mutex, il est créé par défaut dans l'espace de noms Local\ (limité à la session). Dans un environnement où le même utilisateur possède plusieurs sessions, via la console et le Bureau à distance, chaque session est traitée comme ayant son propre Mutex distinct, si bien qu'une seconde session peut lancer l'application normalement. Pour réduire cela à une seule instance à travers les sessions, le préfixe Global\ est indispensable. Mais Global\ seul aboutit à une portée partagée par tous les utilisateurs de la machine ; si vous voulez une instance par utilisateur, il faut aussi intégrer le SID de l'utilisateur dans le nom.
Comment mettre au premier plan la fenêtre de l'instance existante ?
Un simple appel à SetForegroundWindow échoue le plus souvent à cause des restrictions imposées par l'OS, et se limite à faire clignoter le bouton dans la barre des tâches. La pratique établie consiste à faire céder, via AllowSetForegroundWindow(ASFW_ANY), le droit de définir le premier plan que détient le second processus que l'utilisateur vient de lancer en double-cliquant, à l'instance existante, puis à demander l'activation via un tube nommé. Dans les cas où cela échoue malgré tout (lancement via le Planificateur de tâches, par exemple), il est raisonnable de se limiter à une notification plutôt que de forcer la mise au premier plan.
Comment faut-il traiter AbandonedMutexException ?
Si le thread propriétaire du Mutex se termine sans appeler ReleaseMutex (par exemple à cause d'un plantage du processus), le prochain thread qui acquiert ce Mutex reçoit une AbandonedMutexException. Cette exception indique en réalité que l'attente elle-même a réussi et que l'appelant a déjà obtenu la propriété du Mutex. La bonne pratique consiste à ne pas l'ignorer, mais à vérifier la cohérence de l'état protégé avant de l'utiliser. Sur le chemin de terminaison normale, appeler explicitement ReleaseMutex dans un bloc finally évite de déclencher inutilement cette exception au prochain démarrage.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog