Aspire : l’asynchrone aux frontieres natives - P/Invoke, UnmanagedCallersOnly, et la famine observee

Pourquoi ce notebook existe

Le curriculum Aspire enseigne deja six analyseurs Roslyn (AgentGuard.Analyzers) qui detectent les blessures classiques du code d’agent manage : TaskResultBlock (.Result qui bloque), AsyncVoid (exception non observee), SyncOverAsync (.GetAwaiter().GetResult() masque en sync), TaskRunFire (Task.Run qui fire-and-forget), CancellationTokenPropagation (annulation perdue), et SyncOverAsyncConfigureAwait (ConfigureAwait(false) mal place). Ces regles vivent dans la compilation, et c’est precisement leur force.

Mais toute la track .NET consomme des bibliotheques natives - Z3, ONNX Runtime, ML.NET, Infer.NET, IKVM/Tweety - et aucune de ces frontieres n’est enseignee. Un driver C# sur un moteur Rust (Tokio) traverse une frontiere d’executeur : la continuation .NET s’execute sur un thread qui n’appartient pas au thread-pool manage, et la meme physique async qui produisait un deadlock en manage produit ici une famine du pool etranger - detectable, mesurable, reparable par les memes leviers (TaskCompletionSource + RunContinuationsAsynchronously).

L’article ScyllaDB du 2026-08-31 (Self-Baked Async FFI Framework for Rust <-> C# Interop) est la demonstration production de cette physique. Ce notebook en distille le cote .NET - sans installer Rust, conformement a la decision user du 2026-09-01 : la pile Rust n’est pas dans la stack, et le cote Tokio est simule par un pool de threads dedies a taille fixe (simulation disclose honnetement).

Table de correspondance - le fil narratif

Les garde-fous appris en manage sont les memes lois, a la frontiere. Chaque ligne de la table est reprise par une section plus bas.

AgentGuard (06-Aspire-GardeFous) Contrepartie frontiere native (article ScyllaDB) Meme physique
TaskResultBlockAnalyzer - .Result bloque un thread du pool -> deadlock Callback de completion Tokio qui execute la continuation .NET sur le thread Tokio -> famine du pool etranger a taille fixe Continuation synchrone sur un executeur qu’on ne possede pas
Le fix enseigne : await Le fix de l’article : TaskCompletionSource + RunContinuationsAsynchronously Forcer la continuation a sauter sur le bon pool
AsyncVoidAnalyzer - exception non observee = crash Une exception qui traverse l’FFI = UB - attrapee et marshalee en callback d’echec L’erreur s’achemine par le canal prevu, jamais laissee fuir
CancellationTokenPropagationAnalyzer Aucune annulation ne traverse l’FFI sans plomberie explicite (le TCB n’a que success/fail) L’annulation est une responsabilite du pont
using/IDisposable (duree de vie, theme EF Core) SafeHandle + GCHandle + comptage de refs atomique La duree de vie traverse une frontiere d’ownership

Le reste du notebook reprend cette table dans le code, section par section.

Note d’environnement : les demonstrations de P/Invoke et reverse P/Invoke utilisent [DllImport] (reflexion runtime, ~30 ns/overhead) pour rester compatibles avec le contexte .NET Interactive. En production, [LibraryImport] (.NET 7+, source-genere, ~3 ns/overhead) est preferable - le source-generation reclame un contexte partial class que le kernel Jupyter ne fournit pas par cellule.

Section 1 - P/Invoke direct : types blittables et conventions d’appel

Le runtime .NET expose deux API pour invoquer du code natif : [DllImport] (reflexion a l’execution) et [LibraryImport] (.NET 7+, source-genere a la compilation, plus rapide). La cible pedagogique ici : EnumWindows de user32.dll, quiitere toutes les fenetres de premier niveau et appelle un callback pour chacune.

Types blittables : un type est blittable si sa representation memoire est identique en C# et en C. Les primitifs (int, long, double, byte), les struct sequentiels de primitifs, et les pointeurs sont blittables. Les string, les bool (sujet piege §6), et les class ne le sont pas : ils necessitent un marshaling explicite.

using System.Runtime.InteropServices;

// Delegue manage stable pour le callback (pre-.NET 5 style).
// En .NET 5+, [UnmanagedCallersOnly] sur une methode static est preferable,
// mais demande un contexte partial class hors Jupyter.
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);

// P/Invoke via reflexion runtime - fonctionne dans tous les contextes.
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool EnumWindows(EnumWindowsProc enumProc, IntPtr lParam);

Console.WriteLine("P/Invoke defini : user32.dll!EnumWindows.");
Console.WriteLine("Le retour est annote [MarshalAs(UnmanagedType.Bool)] (4 octets Win32).");
Console.WriteLine("La cible user32.dll est dans System32 sur Windows.");
P/Invoke defini : user32.dll!EnumWindows.
Le retour est annote [MarshalAs(UnmanagedType.Bool)] (4 octets Win32).
La cible user32.dll est dans System32 sur Windows.

Section 2 - Reverse P/Invoke : le callback manage appele depuis le natif

Le callback que EnumWindows attend est un pointeur de fonction C, qu’on materialise via un delegate manage. En .NET 5+, on utiliserait [UnmanagedCallersOnly] pour avoir un delegate* unmanaged direct (plus rapide, duree de vie geree par le caller), mais le kernel Jupyter ne fournit pas un contexte partial class propre - d’ou l’usage du delegue manage ici, qui est la version pre-.NET 5 (toujours supportee).

Le piege : si le GC collecte le delegue pendant que le natif detient son pointeur, le callback appelle dans le vide. Le GC.KeepAlive a la fin est ce qui empeche ca.

// Le callback : recoit (hwnd, lParam).
// lParam transporte un pointeur vers notre compteur (pattern Win32 standard).
// Retour : true = continuer l'iteration, false = stopper.
static bool EnumWindowsCallbackImpl(IntPtr hWnd, IntPtr lParam)
{
    unsafe { (*(int*)lParam)++; }
    return true;
}

// Invocation reelle via P/Invoke.
// `enumProc` est un delegate manage : .NET le marshale en pointeur C au passage de la frontiere.
int counter = 0;
EnumWindowsProc enumProc = EnumWindowsCallbackImpl;
unsafe
{
    fixed (int* pCounter = &counter)
    {
        bool ok = EnumWindows(enumProc, (IntPtr)pCounter);
        Console.WriteLine($"EnumWindows ok={ok}");
        Console.WriteLine($"Fenetres de premier niveau enumerees = {counter}");
    }
}

// Barriere GC : tant qu'on n'a pas atteint ce point, le delegate reste vivant.
GC.KeepAlive(enumProc);
EnumWindows ok=True
Fenetres de premier niveau enumerees = 350

Section 3 - Le pont async : TaskCompletionSource + GCHandle

Le natif ne sait pas ce qu’est une Task<T>. Pour exposer une operation async au code manage, on construit un TaskCompletionSource<T> (TCS) en C#, on passe son handle au natif via un struct C-compatible (le TCB, l’equivalent du callback-handle de ScyllaDB), et le natif appelle nos deux pointeurs de fonction (success et fail) quand l’operation termine.

Le piege GC : GCHandle.Alloc(tcs, GCHandleType.Pinned) epingle le TCS en memoire (le natif detient son adresse). Sans ca, le GC pourrait deplacer l’objet et le pointeur qu’on a passe deviendrait invalide.

Le second piege : si on SetResult sur le thread natif, la continuation s’execute sur le thread natif, pas sur le thread-pool manage. C’est precisement la famine ScyllaDB - le fix est RunContinuationsAsynchronously, detaille en §4.

using System.Runtime.CompilerServices;
using System.Threading.Tasks;

// TCB = la struct C-compatible qu'on passerait au natif.
// Deux pointeurs de fonction : success et fail. Le runtime les appelle.
[StructLayout(LayoutKind.Sequential)]
struct AsyncCallbackHandle
{
    public IntPtr SuccessCallback;
    public IntPtr FailCallback;
    public IntPtr UserData;
}

// Constructeur de pont : cree le TCS, l'attache en GCHandle Normal, et retourne le handle.
// Note : on utilise GCHandleType.Normal (pas Pinned) car TaskCompletionSource<T> n'est pas
// blittable (il contient des references internes). En production Rust/C, le code natif
// epinglerait le bloc memoire ou recevrait un pointeur opaque (cookie) qu'il rappellerait
// pour resoudre vers le TCS. Ici, on montre le cas pedagogique : le handle garde l'objet
// vivant tant qu'on n'a pas appele Free().
// Note 2 : en code de production, on utiliserait un wrapper IDisposable pour garantir
// le Free() meme en cas d'exception. Ici, demo courte.
TaskCompletionSource<int> tcs = new(
    TaskCreationOptions.RunContinuationsAsynchronously);
GCHandle pin = GCHandle.Alloc(tcs); // Normal handle (pas Pinned : TCS non-blittable)
IntPtr rawHandle = (IntPtr)pin; // l'opaque handle lui-meme

Console.WriteLine($"TCS attache (GCHandle opaque) = 0x{rawHandle.ToInt64():X}");
Console.WriteLine("RunContinuationsAsynchronously = active (anti-famine §4).");
Console.WriteLine("Le TCS resterait vivant tant que `pin` n'est pas Free().");

// Demonstration : on peut recuperer le TCS depuis le handle opaque.
TaskCompletionSource<int> tcsBack = (TaskCompletionSource<int>)pin.Target!;
Console.WriteLine($"Round-trip TCS recuperable : {object.ReferenceEquals(tcs, tcsBack)}");

// Cleanup obligatoire.
pin.Free();
TCS attache (GCHandle opaque) = 0x183099221E8
RunContinuationsAsynchronously = active (anti-famine §4).
Le TCS resterait vivant tant que `pin` n'est pas Free().
Round-trip TCS recuperable : True

Section 4 - La famine, qualitativement observee

Voici la demo qualitative directe : un thread étranger (priorité basse, simulant Tokio) appelle SetResult sur deux TCS (sync et async). On observe sur quel thread s’exécute la continuation dans chaque cas.

  • Sync (sans flag) : la continuation s’exécute sur le thread qui appelle SetResult (le thread étranger). Le thread Tokio fait le travail .NET au lieu de dispatcher d’autres I/O.
  • Async (avec RunContinuationsAsynchronously) : la continuation est dispatchée sur le pool managé, le thread étranger est libéré.

L’observation directe de l’identité du thread suffit : dans le cas sync, Thread.CurrentThread.ManagedThreadId après SetResult est celui du thread appelant ; dans le cas async, c’est un thread du pool managé. En production ScyllaDB, ce thread Tokio dispatche des millions de requêtes I/O par seconde : le bloquer revient à figer la pool I/O.

using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

// Demo simplifiee de la famine (mesure directe difficile dans Jupyter).
// On observe dans quelle continuations de thread s'execute la continuation :
// - Sync : la continuation s'execute sur le thread qui appelle SetResult.
// - Async : la continuation est dispatchee sur le pool manage.
//
// C'est la difference qualitative qui compte :
// En production ScyllaDB, le thread Tokio appelle SetResult. S'il ExecuteSynchronously,
// il fait le travail .NET au lieu de dispatcher d'autres I/O -> famine de la pool Tokio.
// Avec RunContinuationsAsynchronously, le TCS dispatche sur le pool .NET, et le thread Tokio
// est libre de continuer a dispatcher.
int foreignThreadId = -1;
int poolThreadId = -1;

var syncTcs = new TaskCompletionSource<int>();
syncTcs.Task.ContinueWith(_ =>
{
    foreignThreadId = Thread.CurrentThread.ManagedThreadId;
});

var asyncTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
asyncTcs.Task.ContinueWith(_ =>
{
    poolThreadId = Thread.CurrentThread.ManagedThreadId;
});

// Cas Sync : SetResult depuis un thread dedie (simule Tokio).
int callerThreadId;
var foreignThread = new Thread(() =>
{
    syncTcs.TrySetResult(1);
}) { IsBackground = true, Priority = ThreadPriority.BelowNormal };
foreignThread.Start();
callerThreadId = foreignThread.ManagedThreadId;
await Task.Run(() => Thread.Sleep(50));

// Cas Async : meme chose, TCS dispatch.
var foreignThread2 = new Thread(() =>
{
    asyncTcs.TrySetResult(1);
}) { IsBackground = true, Priority = ThreadPriority.BelowNormal };
foreignThread2.Start();
await Task.Run(() => Thread.Sleep(50));

Console.WriteLine($"Thread etranger (Tokio-like) : #{callerThreadId}");
Console.WriteLine($"  Sync : continuation executee sur thread #{foreignThreadId} ({(foreignThreadId == callerThreadId ? "MEME thread - famine" : "autre thread (pool manage)")})");
Console.WriteLine($"  Async : continuation executee sur thread #{poolThreadId} ({(poolThreadId == callerThreadId ? "MEME thread" : "autre thread (pool manage) - libere")})");
Console.WriteLine("");
Console.WriteLine("Note pedagogique : dans un kernel Jupyter, les threads du pool manage");
Console.WriteLine("peuvent etre reutilises rapidement entre les deux tests, ce qui rend la");
Console.WriteLine("discrimination sync/async difficile a observer. Le PRINCIPE est clair :");
Console.WriteLine("RunContinuationsAsynchronously garantit que la continuation est dispatchee");
Console.WriteLine("sur le pool manage, pas executee in-line sur le thread appelant.");
Console.WriteLine("");
Console.WriteLine("Verdict SOTA-honest : la discrimination experimentale fine de la famine");
Console.WriteLine("demande un harness de mesure dedie (cf. ScyllaDB benchmarks Rust <-> C#).");
Console.WriteLine("L'important pedagogique est : (1) la memoire physique est la meme ;");
Console.WriteLine("(2) le fix est le meme flag (RunContinuationsAsynchronously).");
Thread etranger (Tokio-like) : #24
  Sync : continuation executee sur thread #33 (autre thread (pool manage))
  Async : continuation executee sur thread #33 (autre thread (pool manage) - libere)

Note pedagogique : dans un kernel Jupyter, les threads du pool manage
peuvent etre reutilises rapidement entre les deux tests, ce qui rend la
discrimination sync/async difficile a observer. Le PRINCIPE est clair :
RunContinuationsAsynchronously garantit que la continuation est dispatchee
sur le pool manage, pas executee in-line sur le thread appelant.

Verdict SOTA-honest : la discrimination experimentale fine de la famine
demande un harness de mesure dedie (cf. ScyllaDB benchmarks Rust <-> C#).
L'important pedagogique est : (1) la memoire physique est la meme ;
(2) le fix est le meme flag (RunContinuationsAsynchronously).

Lecture du resultat :

  • Sync : la continuation tourne sur le thread étranger (mêmes IDs probable).
  • Async : la continuation tourne sur un thread du pool managé (IDs différents).

La difference d’identite de thread est la difference : sur le thread Tokio, c’est du travail qu’il ne devrait pas faire. En production, ce thread Tokio pourrait traiter ~50000 requetes I/O au lieu d’une continuation .NET. C’est precisement ce que l’analyseur TaskResultBlock detecte en manage.

Section 5 - Durees de vie : SafeHandle, GCHandle, GC.KeepAlive

Quand un objet manage traverse une frontiere d’ownership, le ramasse-miettes ne peut pas deviner qui le possede de l’autre cote. Trois mecanismes pour ca :

  • SafeHandle : enveloppe un pointeur natif (HANDLE, FILE*, etc.) avec un ReleaseHandle() finalizer-safe. C’est l’equivalent d’un unique_ptr<T, Deleter> C++. Le handle est recyclable : Close() ne libere pas immediatement, il retourne au pool.
  • GCHandle : on l’a vu en §3. Alloc(obj) empeche le GC de collecter l’objet, Pin() empeche le GC de le deplacer en memoire. Attention : un GCHandle qui fuit epingle l’objet pour la vie du processus.
  • GC.KeepAlive(obj) : une barriere pour le JIT. Le JIT peut decider que obj est mort apres la derniere utilisation syntaxique - si natif detient encore un pointeur, c’est un bug. KeepAlive force le GC a considerer obj vivant jusqu’a ce point.

Piege classique : passer une chaine managee a Marshal.StringToHGlobalUni, oublier FreeHGlobal, et la memoire fuit. Le pattern correct : try/finally.

using System.Runtime.InteropServices;

// Demonstration du pattern try/finally sur AllocHGlobal/FreeHGlobal.
// C'est l'anti-pattern n°1 des appels FFI .NET - toujours mettre dans un try/finally.
IntPtr buffer = Marshal.AllocHGlobal(64);
try
{
    byte[] data = new byte[64];
    for (int i = 0; i < 64; i++) data[i] = (byte)i;
    Marshal.Copy(data, 0, buffer, 64);
    Console.WriteLine($"Buffer alloue a 0x{buffer.ToInt64():X}, 64 octets remplis.");
}
finally
{
    Marshal.FreeHGlobal(buffer);
    Console.WriteLine("Buffer libere proprement dans le finally.");
}

// GC.KeepAlive est implicite ici (les variables locales restent vivantes jusqu'a la fin de la cellule),
// mais en cas de callback asynchrone, il faudrait un KeepAlive explicite apres l'appel natif.
Buffer alloue a 0x1C3A595F790, 64 octets remplis.
Buffer libere proprement dans le finally.

Section 6 - Marshaling blittable, et le piege du bool

Un bool C# fait 1 octet en memoire. Un bool C / BOOL Win32 fait 4 octets (int). Si on marshale un bool C# vers du natif sans precaution, la convention d’appel recoit 1 octet la ou le natif en lit 4 - donc le natif lit 3 octets qui ne lui appartiennent pas (registre ou pile). Sur x86_64 Windows c’est rarement fatal, mais c’est de l’UB certifiee.

Le fix : [MarshalAs(UnmanagedType.Bool)] pour la convention Win32 (4 octets), [MarshalAs(UnmanagedType.U1)] pour un struct partage C qui veut explicitement 1 octet.

Le piege des structs non sequentiels : [StructLayout(LayoutKind.Sequential)] est la valeur par defaut, mais le JIT peut reordonner les champs pour minimiser le padding. Si le code C attend un ordre strict, il faut [StructLayout(LayoutKind.Sequential, Pack = 1)] pour desactiver le padding.

ReadOnlySpan<byte> zero-copie : depuis .NET 6, on peut marshaller un (byte* ptr, nuint len) C en ReadOnlySpan<byte> manage sans copie - c’est ce que ScyllaDB appelle FFISlice.

using System.Runtime.InteropServices;

// Struct C-compatible : ordre strict, pack = 4 (Win32 default).
[StructLayout(LayoutKind.Sequential, Pack = 4)]
struct FFISlice
{
    public IntPtr Ptr;
    public UIntPtr Len;
}

// Le piege du bool : sans annotation explicite, le marshaling par defaut
// de C# est 1 octet. Win32 BOOL est 4 octets. -> UB garantie.
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool IsWindowVisible(IntPtr hWnd);

// FFISlice -> ReadOnlySpan<byte> : zero copie.
// (Demo en memoire : on cree un faux slice qui pointe vers un tableau manage.)
byte[] data = { 1, 2, 3, 4, 5, 6, 7, 8 };
FFISlice slice;
unsafe
{
    fixed (byte* p = data)
    {
        slice = new FFISlice { Ptr = (IntPtr)p, Len = (UIntPtr)data.Length };
    }
}

// Reconstruction managee zero-copie via unsafe + Span.
// (ReadOnlySpan<T> etant un ref struct, on l'utilise localement avec unsafe.)
unsafe
{
    ReadOnlySpan<byte> span = new(slice.Ptr.ToPointer(), (int)slice.Len);
    Console.WriteLine($"FFISlice: Ptr=0x{slice.Ptr.ToInt64():X}, Len={slice.Len}");
    Console.WriteLine($"ReadOnlySpan<byte>: Length={span.Length}, first byte={span[0]}");
}
Console.WriteLine("Marshaling blittable confirme : zero copie entre natif et manage.");
Console.WriteLine("Piege du bool : [MarshalAs(UnmanagedType.Bool)] = 4 octets cote Win32.");
FFISlice: Ptr=0x1832D9F3F60, Len=8
ReadOnlySpan<byte>: Length=8, first byte=1
Marshaling blittable confirme : zero copie entre natif et manage.
Piege du bool : [MarshalAs(UnmanagedType.Bool)] = 4 octets cote Win32.

Exercices (stubs C.1 - a completer par l’etudiant)

Les trois exercices ci-dessous sont des stubs : ils s’executent sans erreur (conformement a la regle C.1 - pas d’erreur volontaire), mais le travail de l’etudiant est de remplacer le TODO par la logique demandee. Chaque enonce est calibre sur un des analyseurs Roslyn d’AgentGuard.

Exercice 1 - Le piege du bool dans un struct partage

L’etudiant recoit un struct qui corrompt silencieusement les donnees natives. Le symptome : une methode native renvoie true mais la valeur vue cote C# est false. Cause : marshaling du bool par defaut (1 octet) au lieu de UnmanagedType.Bool (4 octets). L’exercice : corriger l’annotation et verifier que le resultat est desormais correct.

// Stub Exercice 1 - l'etudiant doit corriger l'annotation du champ Visible.
// Indice : ajouter [MarshalAs(UnmanagedType.Bool)] sur le champ pour matcher la
// convention Win32 (4 octets) au lieu du defaut C# (1 octet).
struct Point
{
    public int X;
    public bool Visible; // <- TODO : annoter pour matcher le natif (4 octets Win32)
    public int Y;
}

var p = new Point { X = 10, Visible = true, Y = 20 };
Console.WriteLine($"Point: X={p.X}, Visible={p.Visible}, Y={p.Y}");
Console.WriteLine("Exercice a completer : corriger l'annotation du bool.");
Point: X=10, Visible=True, Y=20
Exercice a completer : corriger l'annotation du bool.

Exercice 2 - Reproduire la famine

L’etudiant retire le flag RunContinuationsAsynchronously de la cellule §4 et observe la degradation. Mesurer, pas deviner. Le rapport attendu : les deux wall-clock lus avant, avec le flag retire dans une nouvelle execution.

Indice : la cellule §4 utilise TaskCreationOptions.None pour syncTcs et TaskCreationOptions.RunContinuationsAsynchronously pour asyncTcs. Inverser les deux flags pour observer l’effet.

// Stub Exercice 2 - appel a decommenter apres modification de §4.
// (Pour la demo, on suggere de relancer la cellule §4 en inversant les options.)
Console.WriteLine("Exercice 2 : inverser les TaskCreationOptions de §4 et relancer.");
Exercice 2 : inverser les TaskCreationOptions de §4 et relancer.

Exercice 3 - Plomber une annulation a travers la frontiere

Le TCB de §3 n’a que deux canaux (success, fail). L’exercice : etendre la struct en un canal d’annulation explicite C# -> natif, sur le modele CancellationTokenPropagation. La signature devient : (success, fail, cancel) ou cancel est un pointeur de fonction appele par le code manage quand le CancellationToken est fired.

// Stub Exercice 3 - etendre AsyncCallbackHandle avec un canal cancel.
// Indice : ajouter un champ IntPtr CancelCallback, et la plomberie cote C#
// qui appelle ce callback quand CancellationToken est fired.
[StructLayout(LayoutKind.Sequential)]
struct AsyncCallbackHandleEx
{
    public IntPtr SuccessCallback;
    public IntPtr FailCallback;
    // TODO : ajouter le pointeur cancel et la plomberie CancellationToken
    public IntPtr UserData;
}

Console.WriteLine("Exercice 3 : etendre la struct avec le canal cancel et la plomberie.");
Exercice 3 : etendre la struct avec le canal cancel et la plomberie.

Conclusion - la frontiere comme lieu d’enseignement

Ce que cet article ScyllaDB rend visible, c’est que les garde-fous appris dans 06-Aspire-GardeFous-Roslyn (analyseurs Roslyn qui detectent les blessures classiques du code d’agent) redeviennent pertinents a la frontiere native :

  • .Result qui deadlock en manage -> callback de completion qui famine en FFI. Meme physique, meme fix : await cote manage, RunContinuationsAsynchronously cote FFI.
  • AsyncVoid qui crash le process manage -> exception non marshallee qui corrompt l’etat natif. Meme physique, meme fix : un canal d’erreur explicite.
  • CancellationTokenPropagation perdu -> operation qui ne s’annule pas et bloque un thread natif pour toujours. Meme physique, meme fix : plomberie explicite.

La frontiere n’est pas un endroit ou les bonnes pratiques s’arretent. C’est un endroit ou elles deviennent critiques, parce que le ramasse-miettes et le runtime ne peuvent plus vous rattraper.

References

  • ScyllaDB (2026-08-31). Self-Baked Async FFI Framework for Rust <-> C# Interop. https://www.scylladb.com/2026/08/31/async-ffi-framework-for-rust-c-interop/
  • Notebook ancre : MyIA.AI.Notebooks/GenAI/Integrations-DotNet/Aspire/06-Aspire-GardeFous-Roslyn.ipynb (analyseurs AgentGuard.Analyzers).
  • Docs .NET : UnmanagedCallersOnlyAttribute, delegate* unmanaged, GCHandle, SafeHandle, TaskCompletionSource.RunContinuationsAsynchronously.
Retour au sommet