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) :
GetLocal(idx)/SetLocal(idx, value)—idxpositif énumère les variables locales déclarées (1-based, dans l'ordre de déclaration) ;idxnégatif énumère les varargs (...) de la fonction si elle en a. Retourne(ScriptValue.None, null)en fin d'énumération — pratique pour boucler sans connaître le nombre de variables à l'avance :for (int idx = 1; ; idx++) { var (value, name) = debug.GetLocal(idx); if (name == null) break; Console.WriteLine($"{name} = {value}"); }GetUpValue(idx)/SetUpValue(idx, value)— upvalues (variables capturées) de la fonction du niveau d'appel, mêmes conventions d'index.GetSelf()— la valeurselfdu niveau, ouScriptValue.Nonesi l'appel n'en a pas (voir Fonctions surselfcomme pseudo-variable dynamique propre à YScript).
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.