Ir al contenido

Ejecución y cancelación de tareas nativas en Rust (`pi-natives`)

Este documento describe cómo crates/pi-natives planifica el trabajo nativo y cómo la cancelación fluye desde las opciones de JS (timeoutMs, AbortSignal) hasta la ejecución en Rust.

  • crates/pi-natives/src/task.rs
  • crates/pi-natives/src/grep.rs
  • crates/pi-natives/src/glob.rs
  • crates/pi-natives/src/fd.rs
  • crates/pi-natives/src/shell.rs
  • crates/pi-natives/src/pty.rs
  • crates/pi-natives/src/html.rs
  • crates/pi-natives/src/image.rs
  • crates/pi-natives/src/clipboard.rs
  • crates/pi-natives/src/text.rs
  • crates/pi-natives/src/ps.rs

task.rs define tres piezas fundamentales:

  1. task::blocking(tag, cancel_token, work)

    • Envuelve napi::AsyncTask / Task.
    • compute() se ejecuta en hilos de trabajo de libuv (para llamadas al sistema bloqueantes/síncronas o con uso intensivo de CPU).
    • Devuelve una Promise<T> de JS.
  2. task::future(env, tag, work)

    • Envuelve env.spawn_future(...).
    • Ejecuta trabajo asíncrono en el runtime de Tokio.
    • Devuelve PromiseRaw<'env, T>.
  3. CancelToken / AbortToken / AbortReason

    • CancelToken::new(timeout_ms, signal) combina un plazo límite + un AbortSignal opcional.
    • CancelToken::heartbeat() es la cancelación cooperativa para bucles bloqueantes.
    • CancelToken::wait() es la espera de cancelación asíncrona (Signal / Timeout / User Ctrl-C).
    • AbortToken permite que código externo solicite la cancelación (abort(reason)).

blocking vs future: modelo de ejecución y selección

Sección titulada «blocking vs future: modelo de ejecución y selección»

Se usa cuando el trabajo tiene uso intensivo de CPU o es fundamentalmente síncrono/bloqueante:

  • escaneo de archivos/regex (grep, glob, fuzzy_find)
  • bucle síncrono interno de PTY (run_pty_sync mediante spawn_blocking)
  • conversiones de portapapeles/imagen/html

Comportamiento:

  • La clausura de trabajo recibe un CancelToken clonado.
  • La cancelación solo se observa donde el código verifica ct.heartbeat()?.
  • Un Err(...) en la clausura rechaza la promesa de JS.

Se usa cuando el trabajo debe hacer await de operaciones asíncronas:

  • orquestación de sesiones de shell (shell.run, executeShell)
  • competencia de tareas (tokio::select!) entre completación y cancelación

Comportamiento:

  • El future puede competir la completación normal contra ct.wait().
  • En la ruta de cancelación, las implementaciones asíncronas típicamente propagan la cancelación a los subsistemas internos (por ejemplo, tokio_util::CancellationToken) y opcionalmente fuerzan la cancelación tras un tiempo de gracia.

Mapeo de API JS ↔ exportación Rust (relevante para tareas/cancelación)

Sección titulada «Mapeo de API JS ↔ exportación Rust (relevante para tareas/cancelación)»
API expuesta a JSExportación Rust (#[napi])PlanificadorConexión de cancelación
grep(options, onMatch?)greptask::blocking("grep", ct, ...)CancelToken::new(options.timeoutMs, options.signal) + ct.heartbeat()
glob(options, onMatch?)globtask::blocking("glob", ct, ...)CancelToken::new(...) + ct.heartbeat() en el bucle de filtrado
fuzzyFind(options)fuzzy_findtask::blocking("fuzzy_find", ct, ...)CancelToken::new(...) + ct.heartbeat() en el bucle de puntuación
shell.run(options, onChunk?)Shell::runtask::future(env, "shell.run", ...)ct.wait() compitiendo contra la tarea de ejecución; se conecta con CancellationToken de Tokio
executeShell(options, onChunk?)execute_shelltask::future(env, "shell.execute", ...)igual que el anterior
pty.start(options, onChunk?)PtySession::starttask::future(env, "pty.start", ...) + spawn_blocking internoCancelToken verificado en el bucle síncrono de PTY mediante heartbeat()
htmlToMarkdown(html, options?)html_to_markdowntask::blocking("html_to_markdown", (), ...)ninguna (token ())
PhotonImage.parse/encode/resizePhotonImage::{parse,encode,resize}task::blocking(...)ninguna (token ())
copyToClipboard/readImageFromClipboardcopy_to_clipboard / read_image_from_clipboardtask::blocking(...)ninguna (token ())

text.rs y ps.rs actualmente no usan task::blocking/task::future y por lo tanto no participan en esta ruta de cancelación.

Ciclo de vida de la cancelación y transiciones de estado

Sección titulada «Ciclo de vida de la cancelación y transiciones de estado»

CancelToken es cooperativo y con estado:

Created
├─ no signal + no timeout -> passive token (never aborts unless externally emplaced)
├─ signal registered -> waits for AbortSignal callback
└─ deadline set -> timeout check becomes active
Running
├─ heartbeat()/wait() sees signal -> AbortReason::Signal
├─ heartbeat()/wait() sees deadline -> AbortReason::Timeout
├─ wait() sees Ctrl-C -> AbortReason::User
└─ no abort -> continue
Aborted (terminal)
└─ first abort reason wins (atomic flag + notifier)

Cancelación antes del inicio vs durante la ejecución

Sección titulada «Cancelación antes del inicio vs durante la ejecución»
  • Antes del inicio / antes de la primera verificación de cancelación:

    • Los usuarios de task::future que compiten con ct.wait() pueden resolver la cancelación inmediatamente una vez que entran en select!.
    • Los usuarios de task::blocking solo observan la cancelación cuando el código de la clausura alcanza heartbeat(). Si la clausura no hace heartbeat temprano, la cancelación se retrasa.
  • Durante la ejecución:

    • blocking: el siguiente heartbeat() devuelve Err("Aborted: ...").
    • future: la rama ct.wait() gana el select!, luego el código cancela la maquinaria asíncrona subordinada (para shell: cancela el token de Tokio, espera hasta 2s, luego aborta la tarea).

Expectativas de heartbeat para bucles de larga duración

Sección titulada «Expectativas de heartbeat para bucles de larga duración»

heartbeat() debe ejecutarse con una cadencia predecible en bucles con conjuntos de trabajo ilimitados o grandes.

Patrones observados:

  • glob::filter_entries: verifica cada entrada antes de filtrar/comparar.
  • fd::score_entries: verifica cada candidato escaneado.
  • grep_sync: verificación explícita de cancelación antes de la fase de búsqueda pesada, además de llamadas a fs-cache que también reciben el token.
  • run_pty_sync: verifica en cada tick del bucle (~16ms de cadencia de sleep) y mata el proceso hijo al cancelar.

Regla práctica: ningún bucle sobre entrada de tamaño externo debe exceder un intervalo corto acotado sin un heartbeat.

Comportamiento de fallos y propagación de errores a JS

Sección titulada «Comportamiento de fallos y propagación de errores a JS»

Ruta de error:

  1. La clausura devuelve Err(napi::Error) (incluyendo cancelación por heartbeat()).
  2. Task::compute() devuelve Err.
  3. AsyncTask rechaza la promesa de JS.

Cadenas de error típicas:

  • Aborted: Timeout
  • Aborted: Signal
  • Errores de dominio (Failed to decode image: ..., Conversion error: ..., etc.)

Ruta de error:

  1. El cuerpo asíncrono devuelve Err(napi::Error) o el fallo del join se mapea (... task failed: {err}).
  2. La promesa generada por task::future se rechaza.
  3. Algunas APIs devuelven intencionalmente resultados de cancelación estructurados en lugar de rechazo (ShellRunResult/ShellExecuteResult con flags cancelled/timed_out y exit_code: None).
  • Cancelación como error: la mayoría de las exportaciones bloqueantes que usan heartbeat()?.
  • Cancelación como resultado tipado: APIs estilo shell/pty de comandos que modelan la cancelación en estructuras de resultado.

Elija un modelo por API y documéntelo explícitamente.

  1. Heartbeat faltante en bucles bloqueantes

    • Síntoma: el timeout/signal parece ignorarse hasta que el bucle termina.
    • Solución: agregar ct.heartbeat()? al inicio del bucle y antes de pasos costosos por elemento.
  2. Secciones largas no cancelables

    • Síntoma: picos de latencia en la cancelación durante una sola llamada grande (decodificación, ordenamiento, compresión, etc.).
    • Solución: dividir el trabajo en fragmentos con límites de heartbeat; si es imposible, documentar la latencia.
  3. Bloqueo del ejecutor asíncrono

    • Síntoma: la API asíncrona se detiene cuando código pesado síncrono se ejecuta directamente en el future.
    • Solución: mover bloques de CPU/síncronos a task::blocking o tokio::task::spawn_blocking.
  4. Semánticas de cancelación inconsistentes

    • Síntoma: una API rechaza al cancelar, otra resuelve con flags, confundiendo a los consumidores.
    • Solución: estandarizar por dominio y mantener la documentación del wrapper alineada.
  5. Olvidar el puente de cancelación en tareas asíncronas anidadas

    • Síntoma: el token externo se cancela pero las tareas internas de lectores/subprocesos siguen ejecutándose.
    • Solución: conectar la cancelación al token/signal interno y aplicar un tiempo de gracia + cancelación forzada como respaldo.

Lista de verificación para nuevas exportaciones cancelables

Sección titulada «Lista de verificación para nuevas exportaciones cancelables»
  1. Clasificar el trabajo correctamente:

    • Con uso intensivo de CPU o bloqueo síncrono -> task::blocking
    • I/O asíncrono / orquestación con await -> task::future
  2. Exponer las entradas de cancelación cuando sea necesario:

    • incluir timeoutMs y signal en las opciones #[napi(object)]
    • crear let ct = task::CancelToken::new(timeout_ms, signal);
  3. Conectar la cancelación a través de todas las capas:

    • bucles bloqueantes: ct.heartbeat()? a intervalos estables
    • orquestación asíncrona: competir con ct.wait() y cancelar sub-tareas/tokens
  4. Decidir el contrato de cancelación:

    • rechazar la promesa con un error de cancelación, o
    • resolver con un tipo { cancelled, timedOut, ... }
    • mantener este contrato consistente para la familia de APIs
  5. Propagar fallos con contexto:

    • mapear errores mediante Error::from_reason(format!("...: {err}"))
    • incluir prefijos específicos de etapa (spawn, decode, wait, etc.)
  6. Manejar la cancelación antes del inicio y durante la ejecución:

    • la verificación/espera de cancelación debe ocurrir antes del cuerpo costoso y durante la ejecución prolongada
  7. Validar que no hay mal uso del ejecutor:

    • no ejecutar trabajo síncrono largo directamente dentro de futures asíncronos sin spawn_blocking/wrapper de tarea bloqueante