Stream: git-wasmtime

Topic: wasmtime / issue #14247 `StoreContextMut::async_call_stac...


view this post on Zulip Wasmtime GitHub notifications bot (Aug 31 2026 at 21:24):

dicej opened issue #14247:

This function is based on the GuestTask::caller field, which is prone to use-after-free errors. Specifically, if a task creates a subtask and then exits before the subtask exits, the caller field will be a table index which is no longer valid, leading to an error at best or silently incorrect behavior at worst (e.g. if that index is reused for a different purpose) if it is used again.

Earlier versions of Wasmtime ensured that GuestTask::caller remained correct regardless of the order in which caller and callee exited. It did so by reparenting subtasks when their callers exited. However, that was based on an earlier version of the component model specification which is no longer relevant, so that code was removed.

One of the main motivations for adding StoreContextMut::async_call_stack was to support attributing a guest->host import call to a corresponding host->guest export call. However, that doesn't need the full call stack, just some sort of scalar identifier to uniquely represent the export call. One way to address that would be to provide an API for passing an embedder-supplied identifier when calling the guest and passing it along to any transitive subtasks created by that call. That identifier could be e.g. a UUID or an Arc<T>, where T is a custom type that contains embedder-specific context for the call.

If the above approach suffices for attribution, we could remove async_call_stack. Alternatively, if we feel async_call_stack still has value for e.g. debugging and error reporting, we could restore the earlier Wasmtime behavior where each task keeps track of its subtasks and reparents them when it exits (with clear internal documentation that those fields are for debugging and error reporting only and not to be used in a "load bearing" way).

view this post on Zulip Wasmtime GitHub notifications bot (Sep 10 2026 at 16:56):

fitzgen added the wasm-proposal:component-model-async label to Issue #14247.

view this post on Zulip Wasmtime GitHub notifications bot (Sep 10 2026 at 16:57):

fitzgen edited issue #14247:

This function is based on the GuestTask::caller field, which is prone to use-after-free errors. Specifically, if a task creates a subtask and then exits before the subtask exits, the caller field will be a table index which is no longer valid, leading to an error at best or silently incorrect behavior at worst (e.g. if that index is reused for a different purpose) if it is used again.

Earlier versions of Wasmtime ensured that GuestTask::caller remained correct regardless of the order in which caller and callee exited. It did so by reparenting subtasks when their callers exited. However, that was based on an earlier version of the component model specification which is no longer relevant, so that code was removed.

One of the main motivations for adding StoreContextMut::async_call_stack was to support attributing a guest->host import call to a corresponding host->guest export call. However, that doesn't need the full call stack, just some sort of scalar identifier to uniquely represent the export call. One way to address that would be to provide an API for passing an embedder-supplied identifier when calling the guest and passing it along to any transitive subtasks created by that call. That identifier could be e.g. a UUID or an Arc<T>, where T is a custom type that contains embedder-specific context for the call.

If the above approach suffices for attribution, we could remove async_call_stack. Alternatively, if we feel async_call_stack still has value for e.g. debugging and error reporting, we could restore the earlier Wasmtime behavior where each task keeps track of its subtasks and reparents them when it exits (with clear internal documentation that those fields are for debugging and error reporting only and not to be used in a "load bearing" way).

view this post on Zulip Wasmtime GitHub notifications bot (Sep 21 2026 at 22:11):

alexcrichton commented on issue #14247:

Talked with @dicej and @lann today at length about this and what to do, and what we settled on was a new trait will be added to Wasmtime:

trait ConcurrentCallHook<T> {
    fn task_start(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()>;
    fn task_enter(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()>;
    fn task_exit(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()>;
    fn task_finish(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()>;
}

The task_{start,finish} hooks bookend the entire lifetime of a "task tree". This'll require new runtime code to manage that but is something we're inevitably going to need anyway for things like task.{current,set-current} (sorry I forget the exact proposal name). The task_{enter,exit} hooks are used to bookend when a task is actively executing work such as executing WebAssemly or polling a host future to see if it's ready yet. This'll all get configure through a new Store-style API similar to the preexisting call-hook API, and this'll additionally all be gated behind the preexisting call-hook cargo feature.


For integrating with tracing specifically we talked a fair bit about this as well. The general idea here is:

We were also thinking that it might make sense for the wasmtime crate, or maybe wasmtime-wasi or something, to have a built-in call hook for doing all the tracing bits. That way users who just want to integrate with tracing in theory have a one-liner, and it also serves as an example of how to do things.

view this post on Zulip Wasmtime GitHub notifications bot (Sep 21 2026 at 22:40):

dicej assigned dicej to issue #14247.

view this post on Zulip Wasmtime GitHub notifications bot (Sep 22 2026 at 13:46):

lann commented on issue #14247:

tracing sketch; a little awkward with standard typestate ownership juggling but not too bad:

#[derive(Default)]
struct TracingCallHook {
    spans: SomeMap<TaskTreeId, SpanState>,
}

const LEVEL: tracing::Level = tracing::Level::INFO;
const SPAN_NAME: &str = "task";

impl TracingCallHook {
    pub fn set_if_enabled<T>(store: Store<T>) -> bool {
        // Skip the hook entirely if e.g. `RUST_LOG=warn`
        if tracing::span_enabled!(LEVEL, SPAN_NAME) {
            store.concurrent_call_hook(TracingCallHook::default());
        }
    }
}

impl<T> trait ConcurrentCallHook<T> for TracingCallHook {
    fn task_start(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        // Note: This will set `Span::current()` as its parent. This could use
        // `current` directly instead but a new span might be less surprising.
        let span = tracing::span!(LEVEL, SPAN_NAME, ?id);
        self.spans.insert(id, SpanState::NotEntered(span));
    }

    fn task_enter(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        if let Some(state) = self.spans.get_mut(id) {
            state.enter();
        }
    }

    fn task_exit(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        if let Some(state) = self.spans.get_mut(id) {
            state.exit();
        }
    }

    fn task_finish(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        self.spans.remove(id);
    }
}

#[derive(Default)]
enum SpanState {
    #[default]
    Empty,
    NotEntered(tracing::Span),
    Entered(tracing::span::EnteredSpan),
}

impl SpanState {
    fn enter(&mut self) {
        *self = match std::mem::take(self) {
            Self::NotEntered(span) => Self::Entered(span.entered()),
            other => other,
        }
    }

    fn exit(&mut self) {
        *self = match std::mem::take(self) {
            Self::Entered(entered) => Self::NotEntered(entered.exit()),
            other => other,
        }
    }
}

view this post on Zulip Wasmtime GitHub notifications bot (Sep 22 2026 at 13:49):

lann commented on issue #14247:

Looking at TaskTreeId again now I wonder if it would be less confusing to just use the existing GuestTaskId and document that these hooks always refer to a host-created task.

view this post on Zulip Wasmtime GitHub notifications bot (Sep 22 2026 at 13:52):

lann edited a comment on issue #14247:

Looking at TaskTreeId again now I wonder if it would be less confusing to just use the existing GuestTaskId and document that these hooks always refer to a host-created task. "Tree" could reenforce the wrong idea that this is typical structured concurrency, especially in this context where the spans are already sort of implying the same.

view this post on Zulip Wasmtime GitHub notifications bot (Sep 22 2026 at 13:53):

lann edited a comment on issue #14247:

Looking at TaskTreeId again now I wonder if it would be less confusing to just use the existing GuestTaskId and document that these hooks always refer to a host-created task. "Tree" could reenforce the wrong idea that this is a typical structured concurrency task tree, especially in this context where the spans are already sort of implying the same.

view this post on Zulip Wasmtime GitHub notifications bot (Sep 22 2026 at 13:54):

dicej commented on issue #14247:

"Tree" could reenforce the wrong idea that this is a typical structured concurrency task tree, especially in this context where the spans are already sort of implying the same.

Yeah, I was thinking the same thing after we chatted yesterday. What about "task group" and thus TaskGroupId?

view this post on Zulip Wasmtime GitHub notifications bot (Sep 22 2026 at 14:00):

lann edited a comment on issue #14247:

Looking at TaskTreeId again now I wonder if it would be less confusing to just use the existing GuestTaskId and document that these hooks always refer to a host-created task. "Tree" could reenforce the wrong idea that this is a typical structured concurrency task tree, especially in this context where the spans are already sort of implying the same.

_Edit: I guess given the semantics of "finish" referring to it as a single task is also confusing.

view this post on Zulip Wasmtime GitHub notifications bot (Sep 22 2026 at 14:01):

lann commented on issue #14247:

"Group" is definitely better than "tree". :+1:

view this post on Zulip Wasmtime GitHub notifications bot (Sep 22 2026 at 14:23):

lann commented on issue #14247:

FWIW an opentelemetry impl looks like it would be pretty similar to the tracing sketch above, with Context replacing Span and slightly different state management, e.g. something like { ctx: Context, entered: Option<ContextGuard> }.

view this post on Zulip Wasmtime GitHub notifications bot (Sep 22 2026 at 14:33):

lann edited a comment on issue #14247:

tracing sketch; a little awkward with standard typestate ownership juggling but not too bad:

#[derive(Default)]
struct TracingCallHook {
    spans: SomeMap<TaskTreeId, SpanState>,
}

impl<T> trait ConcurrentCallHook<T> for TracingCallHook {
    fn task_start(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        // Creates a new span if e.g. `RUST_LOG=debug`, otherwise uses `Span::current()`.
        let span = tracing::debug_span!("task", ?id).or_current();
        // A _disabled_ span can still be a parent, but an _empty_ span cannot.
        if !span.none() {
            self.spans.insert(id, SpanState::NotEntered(span));
        }
    }

    fn task_enter(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        if let Some(state) = self.spans.get_mut(id) {
            state.enter();
        }
    }

    fn task_exit(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        if let Some(state) = self.spans.get_mut(id) {
            state.exit();
        }
    }

    fn task_finish(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        self.spans.remove(id);
    }
}

#[derive(Default)]
enum SpanState {
    #[default]
    Empty,
    NotEntered(tracing::Span),
    Entered(tracing::span::EnteredSpan),
}

impl SpanState {
    fn enter(&mut self) {
        *self = match std::mem::take(self) {
            Self::NotEntered(span) => Self::Entered(span.entered()),
            other => other,
        }
    }

    fn exit(&mut self) {
        *self = match std::mem::take(self) {
            Self::Entered(entered) => Self::NotEntered(entered.exit()),
            other => other,
        }
    }
}

view this post on Zulip Wasmtime GitHub notifications bot (Sep 23 2026 at 19:48):

lann edited a comment on issue #14247:

tracing sketch; a little awkward with standard typestate ownership juggling but not too bad:

#[derive(Default)]
struct TracingCallHook {
    spans: SomeMap<TaskTreeId, SpanState>,
}

impl<T> trait ConcurrentCallHook<T> for TracingCallHook {
    fn task_start(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        // Creates a new span if e.g. `RUST_LOG=debug`, otherwise uses `Span::current()`.
        let span = tracing::debug_span!("task", ?id).or_current();
        // A _disabled_ span can still be a parent, but an _empty_ span cannot.
        if !span.none() {
            self.spans.insert(id, SpanState::NotEntered(span));
        }
    }

    fn task_enter(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        if let Some(state) = self.spans.get_mut(id) {
            state.enter();
        }
    }

    fn task_exit(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        if let Some(state) = self.spans.get_mut(id) {
            state.exit();
        }
    }

    fn task_finish(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        self.spans.remove(id);
    }
}

#[derive(Default)]
enum SpanState {
    #[default]
    Empty,
    NotEntered(tracing::Span),
    Entered(tracing::span::EnteredSpan),
}

impl SpanState {
    fn enter(&mut self) {
        *self = match std::mem::take(self) {
            Self::NotEntered(span) => Self::Entered(span.entered()),
            other => other,
        }
    }

    fn exit(&mut self) {
        *self = match std::mem::take(self) {
            Self::Entered(entered) => Self::NotEntered(entered.exit()),
            other => other,
        }
    }
}

_Edit_: notably this example probably wouldn't work with a real implementaion of ConcurrentCallHook because tracing::span::EnteredSpan is !Send.

view this post on Zulip Wasmtime GitHub notifications bot (Sep 23 2026 at 19:48):

lann edited a comment on issue #14247:

tracing sketch; a little awkward with standard typestate ownership juggling but not too bad:

_Edit_: notably this example probably wouldn't work with a real implementaion of ConcurrentCallHook because tracing::span::EnteredSpan is !Send.

#[derive(Default)]
struct TracingCallHook {
    spans: SomeMap<TaskTreeId, SpanState>,
}

impl<T> trait ConcurrentCallHook<T> for TracingCallHook {
    fn task_start(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        // Creates a new span if e.g. `RUST_LOG=debug`, otherwise uses `Span::current()`.
        let span = tracing::debug_span!("task", ?id).or_current();
        // A _disabled_ span can still be a parent, but an _empty_ span cannot.
        if !span.none() {
            self.spans.insert(id, SpanState::NotEntered(span));
        }
    }

    fn task_enter(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        if let Some(state) = self.spans.get_mut(id) {
            state.enter();
        }
    }

    fn task_exit(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        if let Some(state) = self.spans.get_mut(id) {
            state.exit();
        }
    }

    fn task_finish(&mut self, store_data: &mut T, id: TaskTreeId) -> Result<()> {
        self.spans.remove(id);
    }
}

#[derive(Default)]
enum SpanState {
    #[default]
    Empty,
    NotEntered(tracing::Span),
    Entered(tracing::span::EnteredSpan),
}

impl SpanState {
    fn enter(&mut self) {
        *self = match std::mem::take(self) {
            Self::NotEntered(span) => Self::Entered(span.entered()),
            other => other,
        }
    }

    fn exit(&mut self) {
        *self = match std::mem::take(self) {
            Self::Entered(entered) => Self::NotEntered(entered.exit()),
            other => other,
        }
    }
}

view this post on Zulip Wasmtime GitHub notifications bot (Sep 23 2026 at 19:53):

lann edited a comment on issue #14247:

Looking at TaskTreeId again now I wonder if it would be less confusing to just use the existing GuestTaskId and document that these hooks always refer to a host-created task. "Tree" could reenforce the wrong idea that this is a typical structured concurrency task tree, especially in this context where the spans are already sort of implying the same.

_Edit: I guess given the semantics of "finish" referring to it as a single task is also confusing._

view this post on Zulip Wasmtime GitHub notifications bot (Oct 01 2026 at 01:08):

dicej closed issue #14247:

This function is based on the GuestTask::caller field, which is prone to use-after-free errors. Specifically, if a task creates a subtask and then exits before the subtask exits, the caller field will be a table index which is no longer valid, leading to an error at best or silently incorrect behavior at worst (e.g. if that index is reused for a different purpose) if it is used again.

Earlier versions of Wasmtime ensured that GuestTask::caller remained correct regardless of the order in which caller and callee exited. It did so by reparenting subtasks when their callers exited. However, that was based on an earlier version of the component model specification which is no longer relevant, so that code was removed.

One of the main motivations for adding StoreContextMut::async_call_stack was to support attributing a guest->host import call to a corresponding host->guest export call. However, that doesn't need the full call stack, just some sort of scalar identifier to uniquely represent the export call. One way to address that would be to provide an API for passing an embedder-supplied identifier when calling the guest and passing it along to any transitive subtasks created by that call. That identifier could be e.g. a UUID or an Arc<T>, where T is a custom type that contains embedder-specific context for the call.

If the above approach suffices for attribution, we could remove async_call_stack. Alternatively, if we feel async_call_stack still has value for e.g. debugging and error reporting, we could restore the earlier Wasmtime behavior where each task keeps track of its subtasks and reparents them when it exits (with clear internal documentation that those fields are for debugging and error reporting only and not to be used in a "load bearing" way).


Last updated: Oct 11 2026 at 02:20 UTC