dicej opened issue #14247:
This function is based on the
GuestTask::callerfield, which is prone to use-after-free errors. Specifically, if a task creates a subtask and then exits before the subtask exits, thecallerfield 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::callerremained 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_stackwas 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 anArc<T>, whereTis 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 feelasync_call_stackstill 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).
fitzgen added the wasm-proposal:component-model-async label to Issue #14247.
fitzgen edited issue #14247:
This function is based on the
GuestTask::callerfield, which is prone to use-after-free errors. Specifically, if a task creates a subtask and then exits before the subtask exits, thecallerfield 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::callerremained 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_stackwas 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 anArc<T>, whereTis 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 feelasync_call_stackstill 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).
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 liketask.{current,set-current}(sorry I forget the exact proposal name). Thetask_{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 newStore-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:
- First
wasmtime-wasi-httpand itshandler.rstypes will schleptracing:::Span::current()over from the original invoking task of an HTTP request to the spawned tokio task with a worker. This'll happen automatically.- Next
task_startwill record, when invoked, whatever the current span is and associate it internally within the call hook to theTaskTreeIdpassed in- The
task_{enter,exit}hooks will enter/exit the span associated with theidprovided, if present- The
task_finishhook will remove the entry associated withidfrom the internal map in the call hookWe were also thinking that it might make sense for the
wasmtimecrate, or maybewasmtime-wasior something, to have a built-in call hook for doing all thetracingbits. That way users who just want to integrate withtracingin theory have a one-liner, and it also serves as an example of how to do things.
dicej assigned dicej to issue #14247.
lann commented on issue #14247:
tracingsketch; 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, } } }
lann commented on issue #14247:
Looking at
TaskTreeIdagain now I wonder if it would be less confusing to just use the existingGuestTaskIdand document that these hooks always refer to a host-created task.
lann edited a comment on issue #14247:
Looking at
TaskTreeIdagain now I wonder if it would be less confusing to just use the existingGuestTaskIdand 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.
lann edited a comment on issue #14247:
Looking at
TaskTreeIdagain now I wonder if it would be less confusing to just use the existingGuestTaskIdand 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.
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?
lann edited a comment on issue #14247:
Looking at
TaskTreeIdagain now I wonder if it would be less confusing to just use the existingGuestTaskIdand 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.
lann commented on issue #14247:
"Group" is definitely better than "tree". :+1:
lann commented on issue #14247:
FWIW an
opentelemetryimpl looks like it would be pretty similar to thetracingsketch above, withContextreplacingSpanand slightly different state management, e.g. something like{ ctx: Context, entered: Option<ContextGuard> }.
lann edited a comment on issue #14247:
tracingsketch; 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, } } }
lann edited a comment on issue #14247:
tracingsketch; 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
ConcurrentCallHookbecausetracing::span::EnteredSpanis!Send.
lann edited a comment on issue #14247:
tracingsketch; 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
ConcurrentCallHookbecausetracing::span::EnteredSpanis!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, } } }
lann edited a comment on issue #14247:
Looking at
TaskTreeIdagain now I wonder if it would be less confusing to just use the existingGuestTaskIdand 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._
dicej closed issue #14247:
This function is based on the
GuestTask::callerfield, which is prone to use-after-free errors. Specifically, if a task creates a subtask and then exits before the subtask exits, thecallerfield 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::callerremained 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_stackwas 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 anArc<T>, whereTis 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 feelasync_call_stackstill 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