// 0. Setup : helpers partages (l'etat persiste entre les cellules .NET Interactive)
#nullable enable
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Collections.Generic;
// Sous Linux et macOS, un process sans terminal lit la largeur de sa console dans l'entree
// terminfo de sa variable TERM. Hors d'un terminal (noyau lance par un service, execution
// papermill), TERM peut manquer ou designer une entree sans largeur (TERM=linux) : la table de
// `connect list` se reduit alors a « … ». La sortie du REPL est capturee, pas affichee : on lui
// declare le terminal minimal TERM=dumb, dont la largeur est connue (80 colonnes).
void TerminalParDefaut(ProcessStartInfo psi)
{
if (!OperatingSystem.IsWindows())
psi.Environment["TERM"] = "dumb";
}
// Execute une commande et retourne stdout+stderr.
string Run(string fileName, string arguments)
{
var psi = new ProcessStartInfo(fileName, arguments)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
TerminalParDefaut(psi);
using var p = Process.Start(psi)!;
var stdout = p.StandardOutput.ReadToEnd();
var stderr = p.StandardError.ReadToEnd();
p.WaitForExit();
return stdout + (stderr.Length > 0 ? "\n[stderr] " + stderr : "");
}
// Resout le binaire CSharpRepl dans le store dotnet tools global (version-agnostique).
// Le paquet est propre a chaque plateforme : dossier net10.0/<rid>/, binaire CSharpRepl.exe
// sous Windows, CSharpRepl sous Linux et macOS.
string FindReplExe()
{
var store = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".dotnet", "tools", ".store", "csharprepl");
if (!Directory.Exists(store)) throw new FileNotFoundException("store csharprepl introuvable: " + store);
var rid = (OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsMacOS() ? "osx" : "linux") + "-" + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();
var exeName = OperatingSystem.IsWindows() ? "CSharpRepl.exe" : "CSharpRepl";
var exe = Directory.EnumerateFiles(store, exeName, SearchOption.AllDirectories)
.FirstOrDefault(f => f.EndsWith(Path.Combine("net10.0", rid, exeName)));
if (exe is null) throw new FileNotFoundException(exeName + " introuvable dans le store (" + rid + ")");
return exe;
}
// Parse une ligne `$env:KEY = "value"` de la sortie de `connect init`.
string ParseEnvLine(string output, string key)
{
var line = output.Split('\n').First(l => l.Contains("$env:" + key + " ="));
return line.Split('=', 2)[1].Trim().Trim('"').Trim();
}
// Racine du repo (marchee ascendante depuis le repertoire courant).
string FindRepoRoot()
{
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
while (dir != null)
{
if (File.Exists(Path.Combine(dir.FullName, "MyIA.CoursIA.sln"))) return dir.FullName;
dir = dir.Parent;
}
throw new DirectoryNotFoundException("racine du repo introuvable (MyIA.CoursIA.sln)");
}
string _replExe = FindReplExe(); // binaire CSharpRepl
string WorkingDir = FindRepoRoot(); // racine du repo
string Repl(string args) => Run(_replExe, args);
// Execute une commande en alimentant stdin (mode pipe : --streamPipedInput).
string RunPiped(string fileName, string arguments, string stdin)
{
var psi = new ProcessStartInfo(fileName, arguments)
{
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
TerminalParDefaut(psi);
using var p = Process.Start(psi)!;
p.StandardInput.Write(stdin);
p.StandardInput.Close();
var stdout = p.StandardOutput.ReadToEnd();
var stderr = p.StandardError.ReadToEnd();
p.WaitForExit();
return stdout + (stderr.Length > 0 ? "\n[stderr] " + stderr : "");
}
string ReplPiped(string args, string stdin) => RunPiped(_replExe, args, stdin);
Process? _app = null; // process de l'application demo
int _pid = 0; // PID de l'application demo
List<string> _appLog = new(); // stdout/stderr capture de l'application demoCSharpRepl attache a un process .NET vivant
Grain DEEP/notebook-dotnet — axe CSharpRepl de la serie The Unexpected AI Stack: C#/.NET (Part 1, Charles Chen 08/2026). Epic #10473, issues #10802 / #10897. See #10473, #10897.
CSharpRepl est un REPL C# qui ne se contente pas d’executer du code dans un processus isole : il peut s’attacher a une application .NET vivante et patcher ses methodes a chaud — sans l’arreter, sans la redemarrer, sans recompiler.
Ce notebook demontre la capacite distinctive : un service de commandes tourne (avec DOTNET_STARTUP_HOOKS), le notebook s’y attache, lit son etat, remplace puis enveloppe une methode a chaud, et observe l’application changer de comportement pendant qu’elle tourne.
Prerequis : dotnet tool install -g csharprepl (le binaire est resolu automatiquement par le notebook).
Plan de route. Le notebook suit une progression en trois temps, et chaque section productrice de code se termine par une cellule de lecture chiffree qui interprete la sortie reelle observee :
- Instrumenter (sections 1-2) : lancer l’application avec le hook, l’identifier dans la liste des processus attachables, y evaluer une premiere expression ;
- Intervenir (sections 3-5) : lire l’etat vivant, remplacer une methode (‘#replace’), l’envelopper (‘#wrap’), inventorier (‘#patches’), tout annuler (‘#revert’) ;
- Deleguer (section 6) : refaire la meme intervention en mode scripte — flux de commandes sur stdin, evaluation one-shot — la forme qu’un agent de programmation peut piloter seule.
Les trois exercices terminaux reprennent chacun un mouvement de la demonstration (remplacer, envelopper, sonder) avec un critere de reussite mesurable dans le log de l’application.
Pourquoi c’est non-trivial (parite Python)
| Capacite | Python | C#/.NET (cette demo) |
|---|---|---|
| Recharger du code | %autoreload (reimporte le module, ne touche pas aux objets existants) |
CSharpRepl : #replace / #wrap sur une methode deja chargee |
| Attacher un debogueur | pdb (attache un debogueur, n’evalue pas dans le contexte du process) |
csharprepl connect <pid> : evalue dans le process vivant, accede a ses statiques et services |
| Modifier le comportement a chaud | requiert de redeployer / reimporter | patch MonoMod applique immediatement et persiste jusqu’a #revert ou exit |
Le point distinctif : %autoreload recharge un module dans le process courant du notebook — il ne peut pas modifier une methode d’un autre process en cours d’execution. CSharpRepl le peut, via le hook de demarrage .NET.
Lecture de la table. Les trois lignes ne comparent pas des equivalents approximatifs : chacune nomme une frontiere de process que Python ne franchit pas. %autoreload agit sur le module du process courant (celui du notebook) — les objets deja construits dans un autre process restent fideles a l’ancien code jusqu’a son arret. pdb transfere le controle (pas a pas) mais n’evalue pas une expression arbitraire dans le contexte du debuggue. Et l’ecosysteme Python n’a pas d’equivalent de DOTNET_STARTUP_HOOKS : un mecanisme runtime natif qui autorise du code a s’injecter au demarrage d’un process .NET quelconque. Toute la demonstration qui suit repose sur cette asymmetrie : cote .NET, la frontiere est franchissable par conception, avec l’outillage standard du SDK — pas par detournement.
1. Le hook de demarrage
csharprepl connect init imprime les deux variables d’environnement qui activent le connecteur dans l’application cible. On les pose dans le shell qui lance l’application (jamais system-wide).
Le programme de demonstration (csharprepl-demo/) est une application console qui boucle en imprimant le prix calcule par OrderService.CalculatePrice(3, 10m) toutes les 500 ms — exactement le genre de service qu’on ne veut pas arreter pour changer une regle de pricing.
Pourquoi une application de pricing comme cible ? Parce que c’est le cas d’usage canonique du hot-patching : une regle metier (ici une remise, demain un taux, un seuil, une TVA) doit changer sans interrompre le service. Redemarrer un process qui boucle en 500 ms, c’est perdre les requetes en vol et l’etat interne (compteurs, caches chauds). Le scenario que le notebook va derouler — lire, patcher, envelopper, annuler, scripter — est exactement la checklist d’un incident de production ou l’on veut corriger d’abord, comprendre ensuite, sans fenetre de maintenance.
Lecture du socle. Trois details de conception qui servent toute la suite :
- Persistance d’etat : .NET Interactive garde les variables de haut niveau (
_app,_appLog,_pid,_replExe) d’une cellule a l’autre — chaque cellule du notebook reapplique ses definitions mais l’etat deja construit survit. C’est ce qui permet de lancer l’application en section 1 et de la retrouver vivante en section 6. FindReplExeversion-agnostique : le binaire est resolu dans~/.dotnet/tools/.store/csharprepl/<version>/...par enumeration, pas par chemin hardcode — une montee de version du tool ne casse pas le notebook (et il echoue avec un message explicite si le tool n’est pas installe). Le paquet etant propre a chaque plateforme, la recherche vise le dossier de la machine (net10.0/<rid>/, par exemplewin-x64oulinux-x64) et le nom du binaire de son systeme :CSharpRepl.exesous Windows,CSharpReplsous Linux et macOS.Runcapture stdout+stderr concatene : le REPL ecrit ses resultats sur stdout mais ses diagnostics sur stderr ; les concatener evite de perdre la moitie des messages d’erreur pendant le debug. Sous Linux et macOS,Rundeclare aussi au REPL un terminal minimal (TERM=dumb) : hors d’un terminal, sans largeur de console connue, la table deconnect listse reduirait a « … ».
// 1a. Recuperer le chemin du hook (sortie de `connect init`, auto-detection machine)
var initOut = Run(_replExe, "connect init --shell pwsh");
var hook = ParseEnvLine(initOut, "DOTNET_STARTUP_HOOKS");
var hosting = ParseEnvLine(initOut, "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES");
Console.WriteLine($"hook : {Path.GetFileName(hook)}");
Console.WriteLine($"hosting : {hosting}");hook : CSharpRepl.InjectedHook.dll
hosting : CSharpRepl.InjectedHook
Lecture. connect init ne connecte rien : il imprime la configuration. Les deux variables documentees par la sortie :
DOTNET_STARTUP_HOOKS = ...CSharpRepl.InjectedHook.dll— le runtime .NET execute cette DLL au demarrage de tout process qui herite de la variable. Le hook installe dans le process cible l’ecouteur qui acceptera les connexions du REPL.ASPNETCORE_HOSTINGSTARTUPASSEMBLIES = CSharpRepl.InjectedHook— l’equivalent pour un hote ASP.NET Core, ou la sequence de demarrage passe parIHostingStartup.
Le point important : on ne patch pas un process pour l’attachabilite — on la lui donne a sa naissance, via son environnement. C’est pourquoi la cellule suivante lance l’application apres avoir pose ces variables sur son ProcessStartInfo uniquement (jamais system-wide : un hook global rendrait tout process .NET de la machine attachable).
// 1b. Lancer l'application cible AVEC le hook (stdout capture -> _appLog)
var psi = new ProcessStartInfo("dotnet", "run --project MyIA.AI.Notebooks/GenAI/Vibe-Coding/docs/csharprepl-demo")
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = WorkingDir,
};
psi.Environment["DOTNET_STARTUP_HOOKS"] = hook;
psi.Environment["ASPNETCORE_HOSTINGSTARTUPASSEMBLIES"] = hosting;
_app = Process.Start(psi)!;
_app.OutputDataReceived += (_, e) => { if (e.Data is not null) lock (_appLog) _appLog.Add(e.Data); };
_app.ErrorDataReceived += (_, e) => { if (e.Data is not null) lock (_appLog) _appLog.Add("[err] " + e.Data); };
_app.BeginOutputReadLine();
_app.BeginErrorReadLine();
// Attendre la ligne "PID=" (le build `dotnet run` precede le demarrage)
var deadline = DateTime.UtcNow.AddSeconds(90);
while (DateTime.UtcNow < deadline)
{
lock (_appLog)
{
var pidLine = _appLog.FirstOrDefault(l => l.Contains("PID="));
if (pidLine is not null) { _pid = int.Parse(pidLine.Split("PID=")[1]); break; }
}
Thread.Sleep(200);
}
Console.WriteLine($"Application demarree, PID={_pid}");Application demarree, PID=3326
Lecture. Plusieurs choses se jouent dans cette cellule :
- La ligne attendue est
PID=...: c’est le handshake que l’application de demonstration imprime quand son service est pret. Tout ce qui precede dans le log, c’est ledotnet runqui compile d’abord (d’ou la generous timeout de 90 s, pas un caprice : un build a froid Roslyn peut etre lent). - La capture utilise
BeginOutputReadLine+ un event handler qui alimente_appLogsouslock— pas unReadToEnd()bloquant : l’application ne s’arrete jamais de son vivant, un read synchrone ne rendrait jamais la main a la cellule. _appLogdevient le tampon d’observation du notebook : toutes les sections suivantes qui “regardent” l’application vivante le font en relisant ce log, pas en redirigeant une deuxieme fois le flux.
2. Attacher le REPL au process vivant
connect list enumere les processus attachables. connect <pid> s’y attache ; on peut alors evaluer des expressions dans le contexte du process — acceder a ses types, ses statiques, ses services, ses donnees. Remarque : une expression sans point-virgule fait imprimer son resultat par le REPL.
// 2a. Lister les processus attachables
Console.WriteLine(Repl("connect list"));
PID │ Process
──────┼──────────────
1475 │ VBCSCompiler
3302 │ dotnet
3326 │ LiveOrderApp
Connect with csharprepl connect <PID>.
Lecture de la sortie. La table liste trois processus attachables, et un seul est notre cible :
dotnet— un hote generique (souvent le build daemon ou un autre runtime) ;VBCSCompiler— le compiler server Roslyn, partage entre les builds de la machine. Il est attachable parce qu’il a herite du hook, mais le patcher n’aurait aucun sens (et serait une excellente facon de corrompre ses propres compilations) ;LiveOrderApp— l’application de demonstration, avec le PID capte au handshake de la section 1 : la correspondance nom/PID est la verification que l’on attaque le bon process.
Les PID changent a chaque execution, et l’ordre des lignes avec eux. Cette selection est une vraie competence operatoire : sur une machine de dev chargee, connect list peut montrer dix processus, et #replace applique au mauvais est un incident. Toujours croiser le PID.
// 2b. Evaluer DANS le process vivant : appeler la methode du service
// (le process n'est ni arrete ni redemarre)
Console.WriteLine(Repl($"connect {_pid} -e \"LiveOrderApp.OrderService.CalculatePrice(5, 7m)\""));35
Lecture. Le resultat 35 est la premiere preuve materiale de l’attach : l’expression CalculatePrice(5, 7m) a ete evaluee dans le process vivant — c’est la methode reelle du service, celle que l’application appelle en boucle, avec son JIT, ses statiques, son etat. Contre-preuve immediate : ce resultat n’existe nulle part dans le notebook avant l’evaluation (aucune reimplementation, aucun mock). La convention observee dans la suite : une expression sans point-virgule fait imprimer sa valeur par le REPL ; avec point-virgule, c’est une instruction — silence. C’est l’equivalent exact de la difference statement/expression dans un REPL Python.
3. Lire l’etat vivant du process
On peut aussi lire les statiques du process — l’etat reel, pas une copie.
// 3. Lire un compteur statique vivant (ici : nombre de calculs effectues)
Console.WriteLine(Repl($"connect {_pid} -e \"LiveOrderApp.OrderService.ComputeCount\""));6
Lecture. ComputeCount donne le nombre de calculs que le service a executes depuis son demarrage (la valeur affichee depend de l’instant ou la cellule s’execute). C’est un statique vivant, pas une copie serializee — si on re-evaluait cette expression dans 1,5 s, le compteur aurait monte (l’application boucle en 500 ms : environ +3 par cycle et demi). C’est la difference entre inspecter (snapshot au debugger, process gele) et sonder (lecture non intrusive, process a pleine vitesse). La cellule exercice 3 fera exactement cette mesure differentielle — preuve que la lecture reflete un process qui vit, pas une photo.
4. Patcher une methode a chaud (#replace)
On definit d’abord une methode de remplacement (meme signature que la cible), puis #replace l’applique au process immediatement. L’application continue de tourner — et son comportement change.
Semantique de #replace. Trois etapes separees, dans cet ordre, et c’est important :
- definir la methode de remplacement dans le contexte du REPL attache (elle n’existe que la — aucune recompilation de l’application) ;
- brancher via
#replace Cible with remplacement; - observer cote application (le log), pas cote REPL.
Le remplacement doit matcher la signature de la cible (int, decimal -> decimal ici). Sous le capot, la reecriture de methode est portee par MonoMod ; le message de confirmation de la cellule suivante est le contrat operationnel : il nomme la methode patchee, sa signature complete et le numero de patch — a garder dans un incident, c’est l’equivalent d’un receipt.
// 4a. Definir la methode de remplacement (remise de 20%)
// (signature identique : int, decimal -> decimal)
Console.WriteLine(Repl($"connect {_pid} -e \"decimal salePrice(int q, decimal u) => q * u * 0.8m;\""));
// 4b. Appliquer le patch a chaud
Console.WriteLine(Repl($"connect {_pid} -e \"#replace LiveOrderApp.OrderService.CalculatePrice with salePrice\""));patched static Decimal LiveOrderApp.OrderService.CalculatePrice(Int32 quantity, Decimal unitPrice) ← salePrice (patch #1)
Lecture. Le message patched static Decimal ... (patch #1) confirme trois choses d’un coup : la cible exacte (methode statique CalculatePrice sur OrderService, signatures Int32, Decimal -> Decimal), la source du remplacement (salePrice), et le fait que c’est le premier patch de la session. Le process n’a pas redemarre — le compteur ComputeCount continue de monter pendant ce temps. Reste a voir si le comportement observable de l’application a reellement change : c’est la cellule suivante, et c’est elle qui fait la demonstration.
// 4c. Observer le changement dans l'application VIVANTE (log capture en memoire)
Thread.Sleep(1200);
lock (_appLog)
{
var tail = _appLog.Where(l => l.Contains("price=")).TakeLast(3);
Console.WriteLine(string.Join(Environment.NewLine, tail));
}[8] price=24,0
[9] price=24,0
[10] price=24,0
Le moment cle de la demonstration. L’application imprime maintenant price=24,0 — alors qu’elle imprimait price=30 (3 x 10) depuis son demarrage. Lecture chiffree :
24,0 = 3 x 10 x 0,8— la remise de 20% desalePrices’applique aux commandes en cours de traitement, pas a une reexecution isolee ;- les index affiches sont consecutifs : aucune commande n’a ete perdue pendant le patch, la boucle de 500 ms n’a jamais ete interrompue ;
- la virgule decimale : l’application formate selon la culture courante du process,
24,0sous une culture francaise,24.0sous une culture anglaise ou invariante — detail qui rappelle qu’on observe le vrai process, avec sa configuration regionale, pas un resultat reconstitue.
Une regle metier vient d’etre changee sur un service en production de demonstration, sans arret, sans redemarrage, sans recompilation. Tout le reste du notebook est une variation sur ce theme.
5. Envelopper (#wrap), inventaire (#patches), annuler (#revert)
#wrap enveloppe la methode courante : le wrapper recoit orig (le delegate original) en premier parametre — c’est le pattern documente pour le wrapping d’une methode statique. #patches liste les patches actifs ; #revert all les annule tous.
// 5a. Definir le wrapper de journalisation (le delegate `orig` vient en premier parametre)
// (les `\\\"` sont l'echappement CommandLineToArgvW : quotes litterales dans l'argv du process cible)
Console.WriteLine(Repl($"connect {_pid} -e \"decimal logged(Func<int, decimal, decimal> orig, int q, decimal u) {{ System.Console.WriteLine(\\\"repl-log: \\\" + q + \\\" x \\\" + u); return orig(q, u); }}\""));
Lecture de l’echappement. La ligne du wrapper parait criblee de \" : la commande transite par trois couches de parsing — la string C# du notebook, puis le decoupage de la ligne d’arguments en argv (regles de CommandLineToArgvW, que .NET applique aussi sous Linux et macOS), puis le parseur du REPL cible. Les \" produisent des quotes litterales dans l’argv recu par le process, ou le corps du wrapper doit rester une expression unique. C’est la friction classique du meta-programmation par ligne de commande ; en mode scripte (section 6), --eval-file l’elimine en passant par un fichier.
Le pattern du wrapper lui-meme : le delegate orig en premier parametre recoit la methode originale (eventuellement deja patchee — voir la pile ci-dessous) ; le wrapper decide de l’appeler, du transformer, du court-circuiter. C’est un decorator runtime, sans AOP compile-time.
// 5b. Appliquer le wrap
Console.WriteLine(Repl($"connect {_pid} -e \"#wrap LiveOrderApp.OrderService.CalculatePrice with logged\""));wrapped static Decimal LiveOrderApp.OrderService.CalculatePrice(Int32 quantity, Decimal unitPrice) ← logged (patch #2)
Lecture. patch #2 s’empile au-dessus du #1 : la pile d’appel devient desormais logged -> salePrice -> corps original. Le message confirme que la cible est toujours la meme methode CalculatePrice — on peut empiler plusieurs patches sur une seule cible, chaque #wrap enveloppant l’etat courant. Ordre d’application = ordre de la pile (dernier wrap = couche externe). C’est ce que l’inventaire de la cellule suivante rend visible et auditable.
// 5c. Inventaire des patches actifs
Console.WriteLine(Repl($"connect {_pid} -e \"#patches\"")); #1 [Replace] static Decimal LiveOrderApp.OrderService.CalculatePrice(Int32 quantity, Decimal unitPrice) ← salePrice
#2 [Wrap] static Decimal LiveOrderApp.OrderService.CalculatePrice(Int32 quantity, Decimal unitPrice) ← logged
Lecture. #patches est l’inventaire auditable de la session : deux entrees, #1 [Replace] puis #2 [Wrap], meme cible, sources distinctes. En intervention sur un process vivant, ce listing est le reflexe d’hygiene : avant de diagnostiquer un comportement etonnant, lister les patches actifs — un #wrap oublie d’une session precedente explique beaucoup de “bugs” inexplicables. La cellule suivante nettoie tout et verifie le retour a l’original.
// 5d. Annuler tous les patches -> l'application revient au comportement original
Console.WriteLine(Repl($"connect {_pid} -e \"#revert all\""));reverted 2 patch(es).
Lecture. reverted 2 patch(es) : la pile est defaite et l’application revient a son comportement d’origine — le log remontrerait price=30 au prochain cycle. La reversibilite est symetrique de l’application : ce qui a ete patche a chaud peut etre depatche a chaud, sans arret. C’est ce qui distingue un outil d’intervention d’une manipulation irreversible : en cas de doute sur un patch, #revert all est toujours disponible, et le process n’a jamais quitte son etat de marche. Un protocole d’intervention honnete se termine toujours par un retour a l’etat nominal verifie.
6. Le mode scripte (piped) : patcher sans interaction humaine
Jusqu’ici, chaque commande passait par un REPL interactif : la cellule lance connect, execute une commande, ferme le REPL. Le mode pipe (--streamPipedInput) inverse le rapport : on envoie un flux de commandes sur l’entree standard du REPL, qui les execute les unes apres les autres dans le process vivant puis se ferme a la fin du flux. C’est le mode de l’agent de programmation : le meme #replace que la section 4, mais scripte — sans terminal, sans invite, reproductible.
--eval <code> fait la meme chose pour une expression unique (le resultat revient sur stdout), --eval-file <fichier> pour un script entier depuis un fichier. C’est exactement ce que l’article Part 2 annonce : « Coding agents will be able to use the same REPL via --eval <code> or --eval-file <file> to manipulate the runtime state of the application ». Ce notebook passe donc de la demonstration interactive a la delegation a un agent.
// 6a. Mode pipe : envoyer le patch PUIS l'evaluation dans le meme flux
// (pas de point-virgule final sur la lambda : le parseur du mode pipe le refuse, CS0201)
var script = "#replace LiveOrderApp.OrderService.CalculatePrice with (int q, decimal u) => q * u * 0.85m\n" +
"LiveOrderApp.OrderService.CalculatePrice(20, 5m)\n";
Console.WriteLine(ReplPiped($"connect {_pid} --streamPipedInput", script));patched static Decimal LiveOrderApp.OrderService.CalculatePrice(Int32 quantity, Decimal unitPrice) ← (int q, decimal u) => q * u * 0.85m (patch #3)
85.00
Lecture du mode pipe. Deux differences structurelles avec les sections 4-5 :
- Une seule session REPL recoit deux commandes sur stdin : le
#replacepuis l’expression d’evaluation, executees en sequence dans le meme flux. Avant, chaque cellule ouvrait son propreconnect, executait une commande, fermait. Ici le canal est un tube — aucun terminal, aucune invite, aucune interaction humaine. - La lambda inline
(int q, decimal u) => q * u * 0.85mremplace la definition prealable desalePrice: le remplacement peut etre anonyme. Et le piege documente en commentaire : pas de point-virgule final sur la lambda en mode pipe — le parseur rejette l’instruction-expression (CS0201), l’expression nue passe.
La sortie combine le receipt du patch (patch #3) et le resultat de l’evaluation (85.00 = 20 x 5 x 0,85) — preuve en un seul flux que le patch est applique ET mesurable.
// 6b. Observer le changement dans l'application VIVANTE : patchee sans aucune interaction humaine
Thread.Sleep(1200);
lock (_appLog)
{
var tail = _appLog.Where(l => l.Contains("price=")).TakeLast(3);
Console.WriteLine(string.Join(Environment.NewLine, tail));
}[15] price=25,50
[16] price=25,50
[17] price=25,50
Lecture. price=25,50 = 3 x 10 x 0,85 : l’application vivante confirme le troisieme comportement de la session (30 -> 24,0 -> 25,50), toujours sans interruption — les index continuent la sequence du log, ils ne repartent pas de zero. La continuite des index est la preuve que le process n’a jamais redemarre. Cette cellule est la verification que le mode scripte ne fait pas que “reussir ses commandes” : il change le comportement reel de l’application, exactement comme le mode interactif — mais sans humain au clavier.
// 6c. --eval : lire l'etat patche en une seule expression (primitive de lecture de l'agent)
Console.WriteLine(Repl($"connect {_pid} --eval \"LiveOrderApp.OrderService.CalculatePrice(10, 2m)\""));17.00
Lecture. --eval est la primitive one-shot : une expression, un resultat sur stdout (17.00 = 10 x 2 x 0,85), sortie propre du REPL. C’est le format qu’un agent de programmation consomme : sans invite, sans session a maintenir, le resultat est parsable sur la sortie standard. Compose avec les commandes de patch, cela forme un protocole complet adressable a une machine : sonder (--eval sur un statique), decider (comparer a l’attendu), corriger (#replace), verifier (re-sonder). La citation de l’article Part 2 dans l’intro de la section decrit exactement cette delegation — la cellule qui suit en est l’implementation jouee.
Exercice 1 : patch #replace d’un calcul de TVA
L’application LiveOrderApp calcule price = quantity * unitPrice (TVA incluse a 20%). Ajoutez une methode withVat qui applique 20% de TVA en plus, puis #replace OrderService.CalculatePrice. L’application doit commencer a imprimer price=36 (3 × 10 × 1,2) au lieu de price=30.
Indice : reprenez exactement le pattern de la section 4 (methode de meme signature, puis #replace).
Etapes conseillees et critere de reussite.
- Definir
withVatpar une expression de meme signature queCalculatePrice:decimal withVat(int q, decimal u) => ...— la TVA de 20% se pose sur le resultat brut, pensezq * u * 1.2m. - Appliquer
#replace LiveOrderApp.OrderService.CalculatePrice with withVat(pattern exact de la cellule 4b, prefixe parconnect {_pid} -e). - Critere de reussite, mesurable dans le log : les lignes
price=passent de30a36(3 x 10 x 1,2). Si le REPL repond par une erreur de signature, relisez les types —decimaldes deux cotes, pasdouble. - Terminez proprement par
#revert allpour ne pas polluer l’exercice suivant (le patch precedent reste sinon empile).
// Exercice a completer : definir withVat puis #replace OrderService.CalculatePrice
// decimal withVat(int q, decimal u) => ...;
// #replace LiveOrderApp.OrderService.CalculatePrice with withVat
// Verifier dans le log que price passe a 36.
Console.WriteLine("Exercice a completer");Exercice a completer
Exercice 2 : #wrap avec journalisation
Enveloppez OrderService.CalculatePrice avec un wrapper qui journalise chaque appel (quantite + prix unitaire) avant de deleguer a l’original — puis #patches pour verifier que le wrap est actif.
Indice : le wrapper recoit orig (le delegate original) en premier parametre.
Etapes conseillees et critere de reussite.
- Definir le wrapper avec
origen premier parametre — pattern exact de la cellule 5a, echappement\"compris si vous passez par-e. - Attention a la destination du journal : le
WriteLinedu wrapper s’execute dans le process cible — il arrive sur le stdout de l’application, donc dans_appLog(visible par le memeTakeLastque les cellules 4c/6b), pas dans la sortie du notebook. - Appliquer
#wrap ... with logged, puis#patches: le critere de reussite est double — l’inventaire affiche votre[Wrap]et le log de l’application melange desormais lignesrepl-log: ...et lignesprice=...a chaque cycle. - Le wrapper doit toujours rendre
orig(q, u)sans le modifier : on journalise, on ne change pas le resultat (difference fondamentale avec l’exercice 1).
// Exercice a completer : wrapper de journalisation puis #wrap
// decimal logged(Func<int, decimal, decimal> orig, int q, decimal u) { ... ; return orig(q, u); }
// #wrap LiveOrderApp.OrderService.CalculatePrice with logged
Console.WriteLine("Exercice a completer");Exercice a completer
Exercice 3 : lecture d’etat vivant multi-cible
La liste connect list peut montrer plusieurs processus attachables (par ex. le kernel .NET Interactive lui-meme). Ecrivez un snippet qui : (1) liste les processus, (2) s’attache a celui de LiveOrderApp, (3) lit le compteur statique OrderService.ComputeCount a deux instants distants de 1,5 s pour constater qu’il augmente — preuve que le process vit et que le REPL lit son etat reel.
Indice : connect <pid> -e "..." retourne le resultat de l’expression (sans point-virgule).
Etapes conseillees et critere de reussite.
connect listpour l’inventaire ; reperer la ligneLiveOrderAppet croiser son PID avec_pid(le tableau peut aussi montrer le kernel .NET Interactive etVBCSCompiler— pas des cibles).- Deux lectures de
OrderService.ComputeCountespacees d’unThread.Sleep(1500)— le squelette commente dans la cellule est directement utilisable, il ne manque que leTrim()sur la seconde lecture et la mise en forme finale. - Critere de reussite, chiffre : a 500 ms par cycle, 1 500 ms d’ecart doivent donner un delta d’environ +3 (intervalle credible 2-4 selon la charge). Un delta nul signifie que vous avez lu un process gele ou une string vide non paree ; un compteur identique deux fois de suite est un signal d’erreur, pas de stabilite.
- Ce type de mesure differentielle est la maniere canonique de prouver qu’une lecture remote reflete un etat vivant — meme principe qu’un healthcheck par deltas, pas par snapshots.
// Exercice a completer : mesurer ComputeCount a deux instants
// var c1 = int.Parse(Repl($"connect {_pid} -e \"LiveOrderApp.OrderService.ComputeCount\"").Trim());
// Thread.Sleep(1500);
// var c2 = ...;
// Console.WriteLine($"count: {c1} -> {c2}");
Console.WriteLine("Exercice a completer");Exercice a completer
7. Arret propre
On detache le REPL (exit ou EOF) et on arrete l’application de demonstration.
// 7. Arreter proprement l'application vivante
_app?.Kill(entireProcessTree: true);
Console.WriteLine("Application arretee.");Application arretee.
Lecture. Kill(entireProcessTree: true) termine le process et ses enfants — un dotnet run a une topologie d’arbre (le runner invoque le binaire compile), tuer seulement le parent laisserait un orphelin qui continuerait d’imprimer. Le REPL attache n’a pas besoin de detach explicite : sa session meurt avec le process cible. Le nettoyage final du protocole : patchs revertes (5d), application arretee, aucun artefact ne survit a la demonstration — l’hygiene de fin compte autant que la demonstration elle-meme.
Nous avons demontre, avec du code execute, les deux faces de CSharpRepl : le REPL interactif s’attache a un process .NET vivant, y evalue des expressions, lit son etat, patche une methode a chaud (#replace), l’enveloppe (#wrap) et annule (#revert) ; le mode scripte (--streamPipedInput / --eval) fait la meme chose sans interaction humaine, en envoyant le flux de commandes sur stdin — la forme delegable a un agent de programmation. L’application change de comportement en continu, sans arret ni redemarrage.
C’est la ligne « Introspection d’un process vivant » de la table de parite de l’Epic #10473 : ce que %autoreload / pdb ne font pas cote Python, le REPL attache le fait nativement cote .NET — la verification et la modification se font dans le process, pas en post-processing, et peuvent etre scriptees (--eval / --eval-file), pas seulement tapees a la main.
Rappel securite : csharprepl connect equivaut a executer du code arbitraire dans le process cible, avec ses privileges. Outil de developpement et de diagnostic — jamais sur un process de production. Aide-memoire des commandes.
| Commande | Role | Section |
|---|---|---|
connect init |
Imprimer les variables d’environnement du hook | 1 |
connect list |
Lister les processus attachables (croiser le PID) | 2 |
connect <pid> -e "<expr>" |
Evaluer une expression dans le process vivant | 2-6 |
#replace Cible with remplacement |
Remplacer une methode a chaud | 4 |
#wrap Cible with wrapper |
Envelopper (orig en premier parametre) |
5 |
#patches |
Inventaire auditable des patches actifs | 5 |
#revert all |
Tout annuler, retour au comportement original | 5 |
connect <pid> --streamPipedInput |
Mode scripte : flux de commandes sur stdin | 6 |
connect <pid> --eval "<expr>" |
Evaluation one-shot, resultat sur stdout | 6 |
Limites honnetes. Le hot-patching ne remplace pas un deploiement : les patches vivent dans le process et meurent avec lui — au redemarrage, l’application recharge son code compile d’origine. C’est un outil d’intervention (diagnostic, attenuation temporaire, experimentation) qui comble la fenetre entre l’identification d’un probleme et son fix deploye, pas un mecanisme de livraison. Et chaque pouvoir liste ici a son symetrique en risque : ce qui patche une remise peut patcher une authentification — d’ou le rappel securite qui suit.