Skip to content

Comments

fix: check for exceptions during promise polling#628

Open
keabarnes wants to merge 3 commits intoDelSkayn:masterfrom
keabarnes:kb/promise-resolution-on-interrupt-exception
Open

fix: check for exceptions during promise polling#628
keabarnes wants to merge 3 commits intoDelSkayn:masterfrom
keabarnes:kb/promise-resolution-on-interrupt-exception

Conversation

@keabarnes
Copy link

@keabarnes keabarnes commented Feb 19, 2026

Description of changes

This PR adjusts the Poll behaviour of PromiseFuture to return early with a Poll::Ready variant and an exception indicator.

Context: I was trying to make use of AsyncRuntime.set_interrupt_handler but noticed that, if there were any await calls in my JS code, it would result in the the JS execution halting but the async_function_promise.into_future().await hanging.

Checklist

  • Added change to the changelog
  • Created unit tests for my feature if needed
    • I'm not sure this is applicable for this change

@keabarnes
Copy link
Author

keabarnes commented Feb 19, 2026

I used the rust-script script below to reproduce and test:

#!/usr/bin/env rust-script
//! ```cargo
//! [dependencies]
//! rquickjs = { version = "0.10", features = ["futures", "loader", "parallel"] }
//! tokio = { version = "1", features = ["full", "rt-multi-thread", "macros", "time"] }
//! ```

use std::{
    sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
    },
    time::Duration,
};

use rquickjs::{AsyncContext, AsyncRuntime, Function, Module, async_with};

const JS_SCRIPT: &str = r#"
export default async function main() {
    console.log("JS: Awaiting Promise.resolve() chain...");

    const result = await Promise.resolve("start")
        .then(v => {
            console.log(`JS: then #1: ${v} -> processing`);
            return "middle";
        })
        .then(v => {
            console.log(`JS: then #2: ${v} -> done`);
            return "end";
        });

    console.log(`JS: Promise chain completed with: ${result}`);
    console.log("JS: Starting busy-wait loop (will be interrupted)");

    // Busy-wait loop that will be interrupted after ~2 seconds
    const start = Date.now();
    const duration = 10000; // 10 seconds
    let lastLog = start;

    while (Date.now() - start < duration) {
        if (Date.now() - lastLog >= 1000) {
            const elapsed = Math.floor((Date.now() - start) / 1000);
            console.log(`JS: Busy-wait loop running... ${elapsed}s elapsed`);
            lastLog = Date.now();
        }
    }

    console.log("JS: Busy-wait loop completed (should NOT see this if interrupted)");
    return "completed";
}
"#;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("=== Reproduction Test ===\n");

    let runtime = AsyncRuntime::new()?;
    let context = AsyncContext::full(&runtime).await?;

    // Cancellation flag - will be set after 2 seconds
    let cancel_flag = Arc::new(AtomicBool::new(false));
    let cancel_flag_for_handler = cancel_flag.clone();

    // Set up interrupt handler
    runtime
        .set_interrupt_handler(Some(Box::new(move || {
            let should_cancel = cancel_flag_for_handler.load(Ordering::SeqCst);
            if should_cancel {
                println!("RUST: Interrupt handler fired, returning true to stop JS execution");
                true
            } else {
                false
            }
        })))
        .await;

    // Spawn a task to set the cancellation flag after 2 seconds
    tokio::spawn(async move {
        println!("RUST: Will trigger cancellation in 2 seconds...");
        tokio::time::sleep(Duration::from_secs(2)).await;
        println!("RUST: Setting cancellation flag NOW");
        cancel_flag.store(true, Ordering::SeqCst);
    });

    println!("RUST: Starting script execution...\n");

    // Timeout around the ENTIRE async_with! block
    let result = tokio::time::timeout(
        Duration::from_secs(10),
        async_with!(context => |ctx| {
            // Set up console.log
            let console = rquickjs::Object::new(ctx.clone())?;
            console.set("log", Function::new(ctx.clone(), |msg: String| {
                println!("{}", msg);
            }))?;
            ctx.globals().set("console", console)?;

            // Declare and evaluate the module
            let module = Module::declare(ctx.clone(), "script", JS_SCRIPT)?;
            let (module, module_promise) = module.eval()?;
            module_promise.into_future::<()>().await?;

            // Get and call the default export
            let main_fn: Function = module.get("default")?;
            let promise: rquickjs::Promise = main_fn.call(())?;

            println!("\nRUST: Called main(), awaiting pure JS async function promise...");
            println!("RUST: (No Rust async functions involved in JS code)\n");

            // Await the promise directly
            let result: Result<String, _> = promise.into_future().await;

            println!("\nRUST: Promise settled!");
            result
        })
    ).await;

    // Clean up
    runtime.idle().await;

    println!("\n=== Result ===");
    match result {
        Ok(Ok(value)) => println!("Success: {}", value),
        Ok(Err(e)) => println!("JS Error: {:?}", e),
        Err(_) => println!("TIMEOUT: async_with! block did not complete within 10 seconds"),
    }

    Ok(())
}

@keabarnes
Copy link
Author

keabarnes commented Feb 19, 2026

Targeting rquickjs = { version = "0.10", features = ["futures", "loader", "parallel"] }, the logs show the indefinite wait behaviour:

❯ ./reproduction.rs
[15:16:41.020] === Reproduction Test ===

[15:16:41.022] RUST: Starting script execution...

[15:16:41.022] RUST: Will trigger cancellation in 2 seconds...
[15:16:41.022] JS: Awaiting Promise.resolve() chain...
[15:16:41.022] RUST: Called main(), awaiting pure JS async function promise...
[15:16:41.022] RUST: (No Rust async functions involved in JS code)

[15:16:41.022] JS: then #1: start -> processing
[15:16:41.022] JS: then #2: middle -> done
[15:16:41.022] JS: Promise chain completed with: end
[15:16:41.022] JS: Starting busy-wait loop (will be interrupted)
[15:16:42.022] JS: Busy-wait loop running... 1s elapsed
[15:16:43.022] JS: Busy-wait loop running... 2s elapsed
[15:16:43.023] RUST: Setting cancellation flag NOW
[15:16:43.023] RUST: Interrupt handler fired, returning true to stop JS execution
[15:16:51.023] === Result ===
[15:16:51.024] TIMEOUT: async_with! block did not complete within 10 seconds

@keabarnes
Copy link
Author

keabarnes commented Feb 19, 2026

Updating to my branch shows the early return when the interrupt is set (which generates the "interrupted" exception):

❯ ./reproduction_fixed.rs
[15:15:38.557] === Reproduction Test ===

[15:15:38.557] RUST: Starting script execution...

[15:15:38.557] RUST: Will trigger cancellation in 2 seconds...
[15:15:38.558] JS: Awaiting Promise.resolve() chain...
[15:15:38.558] RUST: Called main(), awaiting pure JS async function promise...
[15:15:38.558] RUST: (No Rust async functions involved in JS code)

[15:15:38.558] JS: then #1: start -> processing
[15:15:38.558] JS: then #2: middle -> done
[15:15:38.558] JS: Promise chain completed with: end
[15:15:38.558] JS: Starting busy-wait loop (will be interrupted)
[15:15:39.558] JS: Busy-wait loop running... 1s elapsed
[15:15:40.558] JS: Busy-wait loop running... 2s elapsed
[15:15:40.559] RUST: Setting cancellation flag NOW
[15:15:40.560] RUST: Interrupt handler fired, returning true to stop JS execution
[15:15:40.560] RUST: Promise settled!

[15:15:40.560] === Result ===
[15:15:40.560] JS Error: Exception

@keabarnes keabarnes marked this pull request as draft February 19, 2026 12:58
@keabarnes
Copy link
Author

Notably, removing the block below from the JS code and using "0.10" also works, so it's only when async is used in JS.

const result = await Promise.resolve("start")
        .then(v => {
            console.log(`JS: then #1: ${v} -> processing`);
            return "middle";
        })
        .then(v => {
            console.log(`JS: then #2: ${v} -> done`);
            return "end";
        });

@keabarnes keabarnes marked this pull request as ready for review February 19, 2026 13:19
@keabarnes
Copy link
Author

I have medium confidence that this is the correct solution given I don't know the impact of these changes in other contexts. I'm happy to change the implementation or create an issue instead if an alternate approach is better.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant