How to Fix: misc fuzzbug: cancellation of async import call not propagated to export side

6 min read

When an async import is cancelled but the export side keeps running, your component graph can deadlock, leak work, or surface impossible states.

This issue points to a broken cancellation propagation path between the importing side of an async boundary and the exporting side that is still producing values. In systems that model async module linking, streaming instantiation, or cross-boundary execution, cancellation must travel in both directions. If the importer aborts but the exporter continues unresolved work, the runtime can retain pending tasks, hold resources longer than necessary, and violate the expected lifecycle of the async operation.

Understanding the Root Cause

At the core, this bug happens because the async import call and the corresponding export-side async producer are not sharing a fully synchronized cancellation contract.

A typical flow looks like this:

  1. An importer starts loading or invoking an async export.
  2. The exporter begins work, often through a promise, task, future, or queued job.
  3. The importer is cancelled, times out, or is otherwise abandoned.
  4. The exporter does not receive that cancellation signal and continues executing.

That leaves the system in a split state: the consumer has terminated interest, but the producer is still active.

Technically, this usually comes from one of these implementation gaps:

  • The cancellation token is attached only to the import-side promise, not to the underlying export-side task.
  • The runtime drops the importer future without invoking explicit abort logic on the exporter.
  • The async bridge resolves ownership incorrectly, so the exporter has no back-reference to the importer lifecycle.
  • Error propagation exists, but abort propagation does not.
  • The importer and exporter use different task schedulers or event queues without a shared cancellation channel.

In fuzzing scenarios, this shows up more often because unusual task interleavings are generated automatically. A fuzzer can create the exact race where one side cancels during module loading, promise chaining, or export resolution. If the runtime assumes successful completion is the common path, these rare cancellation paths remain under-tested until fuzzing exposes them.

The practical impact is serious:

  • Resource leaks from work that should have been stopped.
  • Stale state transitions where exporters complete after importers are gone.
  • Use-after-free risks in lower-level runtimes if cancellation also controls lifetime.
  • Hung tests when unresolved exporter work remains attached to the runtime.

Step-by-Step Solution

The fix is to make cancellation a first-class part of the async import/export contract. The importer must not merely abandon its local await; it must actively notify the export-side operation that the request is no longer valid.

1. Identify the async ownership boundary

First, locate the code path where the import call creates or references export-side work. Look for:

  • Dynamic import handlers
  • Module linking hooks
  • Async export resolvers
  • Promise or future wrappers around exported values

You need a single place where both sides can share a cancellation handle.

2. Introduce a shared cancellation primitive

Use a token, abort handle, or task context that both importer and exporter can observe.

struct AsyncLinkOperation {
    importer_task_id: TaskId,
    exporter_task_id: TaskId,
    cancelled: AtomicBool,
}

impl AsyncLinkOperation {
    fn cancel(&self) {
        self.cancelled.store(true, Ordering::SeqCst);
    }

    fn is_cancelled(&self) -> bool {
        self.cancelled.load(Ordering::SeqCst)
    }
}

The key idea is simple: the importer and exporter must reference the same operation state.

3. Propagate importer cancellation immediately

When the import-side future is dropped, aborted, or timed out, invoke explicit cancellation logic.

async fn run_import(op: Arc<AsyncLinkOperation>) -> Result<Value, Error> {
    let result = poll_export(op.clone()).await;
    result
}

fn cancel_import(op: &Arc<AsyncLinkOperation>) {
    op.cancel();
    wake_export_side(op);
}

This step is where many implementations fail. They allow the importer to disappear but never notify the export side.

4. Make the export side cancellation-aware

The exporter must check for cancellation before and during async progress.

async fn poll_export(op: Arc<AsyncLinkOperation>) -> Result<Value, Error> {
    if op.is_cancelled() {
        return Err(Error::Cancelled);
    }

    let chunk = load_next_stage().await?;

    if op.is_cancelled() {
        return Err(Error::Cancelled);
    }

    Ok(chunk)
}

If the export side performs long-running work, add cancellation checks at every re-entry point: before await, after await, and before publishing results.

5. Prevent late result publication

Even if export-side work finishes after cancellation, it must not publish success into a dead importer path.

fn complete_export(op: &AsyncLinkOperation, value: Value) -> Result<Value, Error> {
    if op.is_cancelled() {
        return Err(Error::Cancelled);
    }

    Ok(value)
}

This protects against race conditions where cancellation and completion happen close together.

6. Clean up scheduler or registry state

If your runtime tracks in-flight module operations, remove cancelled links from the registry.

fn finalize_operation(registry: &mut OperationRegistry, id: OperationId) {
    registry.remove(id);
}

Without this cleanup, fuzzing may continue to report leaked or stuck operations even after logical cancellation is fixed.

7. Add a regression test for cancellation propagation

Create a test that cancels the importer while the exporter is still pending, then verify the exporter receives the abort and does not complete successfully.

#[test]
fn cancellation_of_async_import_propagates_to_export_side() {
    let op = Arc::new(AsyncLinkOperation::new());

    let import_task = spawn_import(op.clone());
    let export_probe = attach_export_probe(op.clone());

    cancel_import(&op);

    assert!(import_task.await.is_err());
    assert_eq!(export_probe.await, ExportState::Cancelled);
}

For fuzz-related regressions, preserve the minimized reproducer and run it in CI as part of the async module or runtime test suite.

8. Validate with stress and fuzz replays

After patching, run:

  • Targeted unit tests for importer/exporter cancellation
  • Stress tests with rapid cancellation timing
  • Replay of the original fuzz-generated input
  • Sanitizer builds if the runtime is implemented in a low-level language
# Example workflow
cargo test async_import_cancellation
cargo fuzz run misc_fuzzbug
ASAN_OPTIONS=detect_leaks=1 cargo test

Adapt the commands to your build system, but the testing strategy should remain the same.

Common Edge Cases

Cancellation arrives after export completion

If the exporter has already committed its result, cancellation should be a no-op. The runtime must define a clear winner between completion and abort to avoid double-settlement.

Multiple importers share one export-side operation

If one exporter fan-outs to several importers, a single importer cancellation should not necessarily cancel the shared producer. Use reference counting or subscriber tracking so cancellation only tears down the exporter when no active importers remain.

Cancellation is propagated, but resource cleanup is not

Stopping logical progress is not enough. Open file handles, network streams, parser buffers, and queued callbacks must also be released.

Exporter converts cancellation into a generic error

That makes debugging harder and may trigger retries incorrectly. Preserve a distinct cancelled state where possible.

Scheduler wakeups keep cancelled tasks alive

Some runtimes continue polling cancelled futures because they remain in a runnable queue. Ensure the scheduler checks task state before re-polling.

Drop-based cancellation is inconsistent

If one code path uses explicit abort while another relies on object destruction, behavior can diverge. Standardize on one cancellation mechanism or ensure both paths converge to the same export-side signal.

FAQ

Why is this bug more visible under fuzzing than in normal execution?

Fuzzing explores rare timing windows and malformed execution sequences that ordinary tests rarely hit. That makes it excellent at finding missing cancellation propagation and race conditions in async runtimes.

Is dropping the import-side future enough to cancel the exporter?

No. Dropping a local future often only removes the consumer. Unless the underlying export-side task is explicitly connected to that lifecycle, it may continue running.

Should cancellation be treated like an error?

Internally, it may flow through the same channels as errors, but semantically it should remain distinct. A cancelled operation is not the same as a failed operation, and conflating them can break retries, logging, and cleanup behavior.

The durable fix for this issue is not just adding one abort check. It is designing the async import/export handshake so ownership, lifecycle, and cancellation are shared explicitly. Once the exporter knows when the importer is gone, the runtime can stop wasted work, avoid inconsistent states, and make fuzz-generated reproducers pass reliably.

Leave a Reply

Your email address will not be published. Required fields are marked *