Déboguer un script depuis l'hôte

Cette page couvre l'API bas niveau (IScript) qu'un hôte .NET utilise pour construire un débogueur — inspecter la pile d'appel en cours, lire/écrire des variables locales et upvalues, et s'accrocher à l'exécution via des hooks (points d'arrêt, pas-à-pas, comptage d'instructions). Pour la bibliothèque debug accessible depuis un script, voir debug — les deux se recoupent en partie (debug.sethook du script et IScript.SetHook de l'hôte partagent le même canal, voir plus bas) mais s'adressent à des publics différents : ici, c'est du code C# qui inspecte/pilote un script, pas l'inverse.

Inspecter la pile d'appel : IDebugInfos

GetDebugCall(level, infos) retourne les informations de débogage du niveau level de la pile d'appel courante (0 = l'appel en cours, 1 = son appelant, etc.) — null si level est hors limites ou correspond au niveau racine du moteur. GetDebugFunc(infos) fait de même pour une fonction dépilée directement (pas nécessairement en cours d'exécution) plutôt que pour un niveau d'appel actif.

IDebugInfos debug = engine.GetDebugCall(0, DebugInfosKind.AllInfos);
if (debug != null)
    Console.WriteLine($"{debug.ShortSource}:{debug.CurrentLine} dans {debug.Name ?? "?"}");

infos (DebugInfosKind, drapeaux combinables) sélectionne ce qui est réellement calculé — chaque catégorie a un coût (résolution de nom symbolique, recherche de ligne, ...), inutile de payer pour ce qui n'est pas consulté :

Valeur Renseigne
Source Source, ShortSource, StartLine, EndLine, What
CurrentLine CurrentLine
Name Name, NameWhat
Params UpValues, Params, IsVarArg
Lines Pousse sur la pile une table des lignes exécutables (candidates à un point d'arrêt)
PushFunction Pousse la fonction elle-même sur la pile
AllInfos CurrentLine \| Name \| Source \| Params

Un IDebugInfos obtenu via GetDebugCall reste lié au niveau d'appel vivant qui l'a produit : accéder à Level (ou toute autre info) après que ce niveau s'est terminé lève un RuntimeError — ne le conservez pas au-delà de la durée du hook/de l'inspection qui l'a produit.

Variables locales, upvalues, self

Sur un IDebugInfos de niveau d'appel (pas une simple fonction dépilée) :

SetLocal échoue silencieusement (retourne false) sur un IDebugInfos qui ne représente pas un niveau d'appel vivant — une fonction simplement dépilée via GetDebugFunc n'a pas de pile à écrire.

Hooks d'exécution : deux canaux indépendants

IScript expose deux mécanismes de hook, isolés l'un de l'autre :

Méthode Canal Visible depuis un script ?
SetHook(hook, events, count) / GetHook() Script Oui — c'est ce que debug.sethook/gethook posent et lisent
SetDebuggerHook(hook, events, count) / GetDebuggerHook() Débogueur (hôte) Non — aucune fonction debug.* n'y a accès

Le canal script (SetHook) est celui déjà documenté côté langage dans debug.sethook — un hôte peut l'utiliser directement (sans passer par le script), mais un script qui appelle lui-même debug.sethook verra ce canal réutilisé/écrasé de la même façon qu'un second appel à debug.sethook l'écraserait.

Le canal débogueur (SetDebuggerHook) existe spécifiquement pour qu'un hôte construisant un vrai débogueur (points d'arrêt, pas-à-pas) n'entre jamais en conflit avec un script qui utilise debug.sethook pour ses propres besoins (auto-instrumentation, profileur maison, etc.) — les deux canaux coexistent, s'exécutent indépendamment sur les mêmes évènements (le canal débogueur en premier si les deux sont actifs pour un même évènement), et ni l'un ni l'autre ne peut lire, écraser, ou même détecter la présence de l'autre. Un script peut donc utiliser debug.sethook sans restriction, y compris pendant une session de débogage pilotée par l'hôte.

Les deux méthodes partagent la même forme :

engine.SetDebuggerHook((script, debug) =>
{
    if (debug.HookEvent == HookEvents.Line)
        Console.WriteLine($"{debug.ShortSource}:{debug.CurrentLine}");
}, HookEvents.Line, count: 0);

events (HookEvents, drapeaux combinables) : Call (entrée dans un appel), Return (sortie d'appel), Line (changement de ligne exécutée — la base d'un point d'arrêt/pas-à-pas), Count (tous les count instructions exécutées, indépendamment des lignes). Appeler SetHook/ SetDebuggerHook avec hook: null (ou events: HookEvents.None) retire le hook du canal concerné.

À l'intérieur du hook, debug.HookEvent indique quel évènement a déclenché l'appel, et debug.CurrentLine la ligne concernée pour Line (-1 pour les autres évènements). Le hook reçoit un IDebugInfos de niveau d'appel (voir ci-dessus) : il peut donc inspecter/modifier les variables locales et upvalues du point d'exécution courant — c'est la base d'un inspecteur de variables lors d'un arrêt sur point d'arrêt.

Construire un point d'arrêt

Un point d'arrêt simple se construit en filtrant l'évènement Line sur le fichier/la ligne visés, puis en suspendant l'exécution — par exemple en bloquant le thread hôte jusqu'à ce que l'utilisateur demande de continuer (l'exécution du script tourne dans l'appel qui a déclenché le hook, donc bloquer ici bloque bien le script, pas l'hôte tout entier tant que ce dernier reste sur un autre thread) :

engine.SetDebuggerHook((script, debug) =>
{
    if (debug.HookEvent != HookEvents.Line) return;
    if (debug.ShortSource != breakpointFile || debug.CurrentLine != breakpointLine) return;
    WaitForContinueCommand(); // bloque jusqu'à ce que l'UI du débogueur autorise la suite
}, HookEvents.Line, count: 0);

Le pas-à-pas ("step over"/"step into") s'obtient en réévaluant à chaque évènement Line (ou Call/ Return pour distinguer entrer/sortir d'un appel) si l'exécution doit encore s'arrêter, plutôt qu'en filtrant sur une ligne fixe.

Arrêt propre vs. arrêt forcé

Un hook (Line/Count) est le mécanisme d'arrêt coopératif : il s'exécute entre deux instructions, avec un état cohérent et inspectable. Si le script est bloqué dans un appel natif/une boucle infinie sans jamais repasser par le fetch-exec de la VM, aucun hook ne se déclenchera — le seul recours est un arrêt non coopératif côté hôte (typiquement : exécuter le script dans un processus séparé et le tuer, plutôt que d'essayer d'interrompre un thread in-process). C'est le choix retenu pour l'éditeur YScript.YEditor (yeditor.exe lance yes.exe en sous-processus pour ses sessions de débogage) — voir documentation/specs/yeditor-rewrite.md §5.2 pour le détail de ce compromis.