Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 :

FrontendJetonDéclencheur
Rust purNoCancel / ()jamais
Rust purAtomicBoolun autre thread / handler ctrlc lève le drapeau
Rust purDeadlinedépassement d’un délai (timeout)
PythonPySignals (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 → KeyboardInterrupt côté Python.
  • Sonder une fois par étape grossière ; coût nul pour NoCancel (inliné).