Interrompre une fonction
Certains opérateurs (mailleurs, solveurs, boucles de raffinement) peuvent
tourner longtemps. On veut alors pouvoir les interrompre proprement —
typiquement par un Ctrl+C depuis Python, mais aussi par un timeout ou un
signal externe depuis un programme Rust. Ce chapitre explique le principe et
comment l’implémenter pour un nouvel opérateur.
Pourquoi un Ctrl+C ne suffit pas
Quand une fonction Rust appelée depuis Python tourne dans une longue boucle,
elle garde le GIL et ne rend jamais la main à l’interpréteur. Or
KeyboardInterrupt n’est levée par Python qu’entre deux bytecodes. Le
Ctrl+C (signal SIGINT) est donc bien enregistré, mais il ne se
déclenchera qu’au retour de la fonction Rust : pendant le calcul, la
combinaison paraît sans effet.
La solution est l’interruption coopérative : la boucle longue vérifie elle-même, à intervalles réguliers, s’il faut s’arrêter.
Le principe : un jeton, pas un détail de frontend
L’écueil serait de faire appeler Python::check_signals directement par
l’opérateur — cela couplerait le cœur de calcul à PyO3 et casserait
l’usage en Rust pur (cf. Compilation et tests, build sans
python-api).
À la place, l’opérateur reçoit un jeton d’interruption abstrait — le trait
Cancel du module interrupt — qu’il interroge périodiquement. C’est le
frontend qui décide comment l’interruption est signalée :
| Frontend | Jeton | Déclencheur |
|---|---|---|
| Rust pur | NoCancel / () | jamais |
| Rust pur | AtomicBool | un autre thread / handler ctrlc lève le drapeau |
| Rust pur | Deadline | dépassement d’un délai (timeout) |
| Python | PySignals (dans src/py, gaté python-api) | Ctrl+C via Python::check_signals |
Le trait est du Rust pur, sans PyO3 :
///
/// ```
/// # use pyrucast::error::PyrucastError;
/// # use pyrucast::interrupt::{Cancel, Deadline};
/// # use std::sync::atomic::{AtomicBool, Ordering};
/// // The token decides **how** the stop is signalled; the operators
/// // n'en savent rien, et restent libres de toute considération de
/// // frontal — Ctrl+C, délai, bouton d'interface.
/// let stop = AtomicBool::new(false);
/// assert!(stop.check().is_ok());
/// stop.store(true, Ordering::Relaxed);
/// assert!(matches!(stop.check(), Err(PyrucastError::Interrupted)));
/// ```
pub trait Cancel {
/// Poll the cancellation state. Called frequently, so keep it cheap.
fn check(&self) -> Result<()>;
}
L’erreur renvoyée est PyrucastError::Interrupted. Sa conversion vers Python
(gatée python-api) produit une vraie KeyboardInterrupt, pas un
RuntimeError générique. Le cœur, lui, ne voit que &dyn Cancel : il reste
PyO3-free et utilisable depuis un programme Rust.
Implémenter l’interruption dans un opérateur
Trois gestes.
1. Faire passer le jeton et le sonder. Le cœur de calcul prend un
&dyn Cancel et l’interroge à chaque tour de boucle (un tour = un événement
grossier — un élément, une couche, une itération de solveur — pour que le
coût d’un check soit négligeable) :
pub fn pave(/* … */, cancel: &dyn Cancel) -> Result<…> {
loop {
cancel.check()?; // ← point d'interruption
// … une étape de travail …
}
}
Sonder une fois par étape grossière suffit ; inutile de throttler par un compteur si chaque tour fait déjà un travail substantiel.
2. Exposer deux formes côté Rust — une simple et une interruptible — pour ne pas imposer un jeton aux appelants qui n’en veulent pas :
pub fn triangulate_surface(contour: &Mesh, et: ElementType, size: Option<f64>) -> Result<Mesh> {
triangulate_surface_cancellable(contour, et, size, &NoCancel)
}
pub fn triangulate_surface_cancellable(
contour: &Mesh, et: ElementType, size: Option<f64>, cancel: &dyn Cancel,
) -> Result<Mesh> { /* … boucle qui sonde `cancel` … */ }
Un programme Rust câble alors ce qu’il veut, sans toucher à Python :
#[test]
fn un_jeton_partage_interrompt_le_mailleur() {
let coords = Handle::new(Coords::new(2).unwrap());
let coins: Vec<Node> = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
.iter()
.map(|p| Node::create_in(coords.clone(), p).unwrap())
.collect();
let mut sm = SubMesh::new(coords.clone(), ElementType::SEG2);
for i in 0..4 {
sm.add_cell(&[coins[i].id(), coins[(i + 1) % 4].id()])
.unwrap();
}
let contour = Mesh::from_submesh(sm);
let stop = Arc::new(AtomicBool::new(false));
// A Ctrl+C handler (the `ctrlc` crate, to add to your own Cargo.toml), a
// supervising thread, a timeout… all arm the same token:
// let s = stop.clone();
// ctrlc::set_handler(move || s.store(true, Ordering::Relaxed)).ok();
let mesh = triangulate_surface_cancellable(&contour, ElementType::TRI3, Some(0.5), &*stop);
assert!(mesh.is_ok()); // rien n'a armé le jeton : le maillage aboutit
// Token armed in advance: the mesher stops at the first checkpoint.
stop.store(true, Ordering::Relaxed);
let interrompu =
triangulate_surface_cancellable(&contour, ElementType::TRI3, Some(0.5), &*stop);
assert!(interrompu.is_err());
// `Deadline::after(Duration::from_secs(10))` would work just as well.
}
3. Brancher le jeton Python dans la couche FFI (src/py, gatée
python-api) — le seul endroit où l’interruption Python rencontre le
cœur :
pub struct PySignals<'py>(pub Python<'py>);
impl Cancel for PySignals<'_> {
fn check(&self) -> Result<()> {
self.0
.check_signals()
.map_err(|_| PyrucastError::Interrupted)
}
}
Le paramètre py: Python<'_> est injecté par PyO3 et n’apparaît pas dans la
signature Python : pyrucast.mesh.triangulate_surface(contour, element_type, size=None) reste
inchangée, mais un Ctrl+C l’interrompt désormais.
Lien avec le parallélisme
Le même AtomicBool est le mécanisme naturel pour interrompre un calcul
parallèle à mémoire partagée : chaque worker sonde le drapeau, et le thread
principal (seul à détenir le GIL côté Python) le lève quand check_signals
détecte le Ctrl+C. Poser le trait Cancel dès maintenant prépare ce terrain
sans coût supplémentaire.
À retenir
- L’interruption est coopérative : l’opérateur sonde, le frontend décide.
- Le cœur reste PyO3-free (
&dyn Cancel) → utilisable en Rust pur. PyrucastError::Interrupted→KeyboardInterruptcôté Python.- Sonder une fois par étape grossière ; coût nul pour
NoCancel(inliné).