Skip to content

Subtasks, wasi:cli/run, host invocations of functions, and relationships within #608

Closed

Description

@alexcrichton

Wasmtime's host API today is that when a function is invoked it returns both the function's return value and TaskExit. The TaskExit value has a single method, block, which is intended to transitively wait for all subtasks spawned by the original root task. This structure is used in a few situations:

In discussions with @lukewagner and @dicej I've realized that Wasmtime's modeling of subtasks in this way is incorrect and needs to change. Effectively the TaskExit return value is going to need to be removed from Wasmtime because subtasks are tied to instances, not tasks themselves.

In doing this, however, it raises the question of what should be happening in all of these cases? For example at what point should wasi:cli/run "finish"? Is that the same answer as --invoke? Or is serve perhaps a bit different? I wanted to open this issue to write down some thoughts and have some possible discussion about this.

In the near-term what I plan to do is to add some sort of "wait for all tasks in this Store" method which will be used in all of these situations. That would mean, however, that a background task would keep the entire store alive and would keep Wasmtime from exiting. That's probably fine in the near-term but I don't think that this is a great long-term solution. I'm also not exactly sure what the solution might be and where it would lie, as the answer could be in WASI idioms or in the component model itself, unsure!

Activity

  1. lukewagner commented on Feb 9, 2026

    @lukewagner
    Member

    Thanks for filing, happy to discuss!

    One idea that came up in #307 in the context of long-running/reused instances is that, just like there is currently a start function, there could also be a stop function. The stop function would be called by the host (or, in the future, by a parent component) to signal "I'd like to tear down your instance; please exit all running tasks so I can do it gracefully before a timeout". In the ideal case, all the component's background tasks would be notified that stop was called (e.g., maybe via guest wasi-libc atexit()) and then exit. But then after a host-determined timeout, the host would ungracefully zap the containing store (like it does now to kill iloops). So then, in the context of a host "wait for all tasks in this Store" method that you mentioned, the first thing this method could do is call the stop function for all component instances in the store, thereby telling all the background tasks to promptly exit to allow prompt graceful shutdown.

  2. badeend commented on Feb 10, 2026

    @badeend
    Member

    I think there's overlap with an issue I opened recently on the WASI repo: WebAssembly/WASI#858

    That focuses only on the wasi:cli/run "foreground" task.
    Yet, similar to this ticket, that WASI ticket has an (as of yet) unresolved question by Lann on how to properly propagate the shutdown request into the subtask/component tree.
    Also it exhibits the same graceful shutdown UX, whether that's a simple timeout or waiting for a second Ctrl+C.

  3. lukewagner commented on Feb 10, 2026

    @lukewagner
    Member

    @badeend Interesting connection! Alex makes a good point on that issue regarding the trickiness of wiring up Ctrl+C to async cancellation in a wasi-libc / sigaction() context. But let's say that there is no WASI sigaction (or: it exists, but the component hasn't dynamically registered a SIGINT callback) and the CLI host receives a Ctrl+C. Instead of simply zapping the whole store or the whole process (which I expect is what would happen now?), maybe the runtime could call the stop functions on all the transitive component instances and then set a timeout to allow them to gracefully shut down before zapping them. And then perhaps wasi-libc could export a stop function (which, like start, we could allow multiple of, so that wasi-libc could do on its own without worrying about whatever else is linked into the component) and then wasi-libc could translate calls to stop into some appropriate { signal or atexit() or both or something else } call that background tasks could register for?

  4. alexcrichton commented on Feb 11, 2026

    @alexcrichton
    CollaboratorAuthor

    One thing I'm now realizing is that my intention with this issue is different from WebAssembly/WASI#858, specifically where WebAssembly/WASI#858 is concerned with cancellation this issue is concerned about the meaning of the window of time between task-return and task-exit. This touches on WASI for sure, but as of now I'm not really sure what it means.

    On one hand this is sort of "life after main returns" which I don't think has any real correspondence to meaning on native platforms (e.g. natively platforms all terminate the process when the main thread exits). Runtimes such as Java (or so I'm told by @dicej) will not exit the process on the main thread exiting but wait for all threads to exit as well. I don't know what to implement in Wasmtime.

    In bytecodealliance/wasmtime#12185 Wasmtime's handling of CLI binaries was updated to avoid exiting the process until all subtasks spawned by the program exited. I'm second-guessing that now of I'm not sure whether we actually want that behavior at all. Even if there were a stop function or some way to signal cancellation, is this even the right point to do so? I can see stop/cancellation being quite relevant to wasi-http for example but for something like the CLI it seems less relevant as by returning from run I'd expect that's the conventional signal for "I'm done here goodbye".

    Basically I want to clarify that even if there were a stop and even if there were a well-defined means of cancellation, I still don't know how to think about the window of time between return and exit. I don't know what WASI should specify runtimes should do when running run within this window of time -- for example I don't know if "block on everything in the store exiting" is correct nor if "cancel everything then block for a grace period then hard kill" is correct. To me the closest equivalent to a native platform is to hard kill everything after run returns with no chance for cleanup. If a guest language wants different behavior (e.g. Java) then it's up to the guest language to avoid returning.

    For wasmtime serve what I'd expect is that the store is "strong referenced" while any streams are in active use (e.g. incoming or outgoing or such). Once all that goes away the store is eligible to get zapped at any time, where in the future we'd use stop and/or cancellation to more gracefully allow that. In this case though I also don't see a purpose for blocking on all spawned subtasks within the store, however.

  5. alexcrichton commented on Feb 11, 2026

    @alexcrichton
    CollaboratorAuthor

    Some more history on the Wasmtime side of things:

  6. added a commit that references this issue on Feb 11, 2026
  7. jellevandenhooff commented on Feb 18, 2026

    @jellevandenhooff

    I have a related (I think?) issue I was thinking about while trying to integrate wasip3 with the go runtime. Specifically, how do goroutines and the go scheduler interact with (sub)tasks from the embedder? If you have a single exposed main function then its lowered exports (invoke and the callback) can drive the go scheduler. But what happens if you have many exports? Should they each drive the go scheduler in turn? But then what happens if one task exits? In general, I think requiring that (sub)tasks invoked by the embedder map cleanly to (sub)tasks inside a component is too strict a requirement: What if you want batch some stuff? Or fire off a background watch thread? Etc.

  8. alexcrichton commented on Feb 20, 2026

    @alexcrichton
    CollaboratorAuthor

    After discussing this with @lukewagner, @dicej, and @tschneidereit at depth we've decided to close this in favor of #612 and #611 as possible follow-ups here. The thinking is that for now hosts will zap components when they're semantically done (e.g. returned from wasi:cli/run or done streaming the response for wasi:http/service) and this can be extended in the future as necessary.


    @jellevandenhooff while not necessarily directly related to this issue or its conclusion, I'll do my best to answer your questions nonetheless. Happy to continue comment here or in a follow-up issue if you desire!

    At a high level what the Go runtime will take advantage of is that component model tasks have a period of time after they have returned but before they have exited to perform extra work. For example all exports from a component have this window (or at least the async ones) where more work can be done without affecting callers (since a caller already has the result). The Go scheduler can decide that one particular component model task becomes The Task, for example, that work is performed within. Part of the conclusion of this issue is that there's no advantage to choosing any one particular task over others.

    To answer some questions directly:

    Specifically, how do goroutines and the go scheduler interact with (sub)tasks from the embedder?

    I think that these will be somewhat orthogonal in the end. Component model tasks are created whenever an export is called, and then it's up to the guest language (Go) to call task.return when this component model task is running. Go would then be responsible for delivering the return value to the right component model task, but otherwise Goroutines can be separated from component model tasks (and could migrate around). Like I mentioned above the scheduler will probably pick a "main component model task" to run background/etc work within, but even that's optional and can be elided if the scheduler dosen't actually have any work.

    In short, a lot of this will be up to the Go runtime itself and the intention is that the component model tasks are pretty flexible in exactly how this is all mapped out.

    But what happens if you have many exports?

    The way I'd think about this is something like:

    • An export is called. This creates a Goroutine and activates the scheduler. The goroutine runs.
    • While there's work to do the scheduler does stuff.
    • Eventually the scheduler will run out of work and something needs to block. In this situation the original export gets its ABI hooked up to say "ok I'm blocked please come back later".
    • If another export is called, then this all happens again. There's still only one scheduler.
    • Events are routed into the scheduler from the context of component model tasks, and whenever an event happens it continues to drain the scheduler of any active work.
    • Eventually a task finishes, in which case either the scheduler is already acting within that task so it task.returns, or it's outside of the context of the task so it needs to wake up the component-model waitable-set that the task is waiting on (all done in guest code).

    The loose model here is that component model tasks are either running or blocked on a component-model waitable-set. What exactly is in that waitable set is up to the Go runtime and it could use the same waitable set for many tasks or example, or some other setup.

    @dicej has thought a lot more about this than I have and might be able to say "Alex you're way off the mark do this instead" or "this is all already done over here in wit-bindgen go go take a look at that".

    Should they each drive the go scheduler in turn?

    Personally I'd say yes. Goroutines shouldn't be considered pinned to any one compoent model task and work can migrate aruond. Or put another way everything lives in the Go scheduler, and then there's some ABI details to ensure that intrinsics are called at the right time and ABI details are sorted out.

    But then what happens if one task exits?

    Go is in control for when a task exits, so it gets to decide if that's the right course of action or whether a task should stay alive longer.

    What if you want batch some stuff? Or fire off a background watch thread? Etc.

    This is something the Go runtime will determine. The closest equivalent is probably "the scheduler has work to do so at least one component model task is kept alive post-return to process this work".

  9. jellevandenhooff commented on Feb 22, 2026

    @jellevandenhooff

    @alexcrichton thanks for your in-depth answer! That explains a lot and makes sense. I wonder, how do you imagine things will evolve in the future, with perhaps better language support mapping to subtasks, or runtimes using the component model's native thread switching? The language in the explainer suggests that async call stacks are expected to be helpful for debugging and understanding what programs are doing:

    The async call stack is also useful for non-semantic purposes such as providing backtraces when debugging, profiling and tracing. While particular languages can and do maintain their own async call stacks in core wasm state, without the Component Model's async call stack, linkage between different languages would be lost at component boundaries, leading to a loss of overall context in multi-component applications.

    That does seem quite valuable to me, but if language runtimes treat subtasks as an opaque implementation detail, that will not give helpful backtraces. It reminds me of trace identifiers in eg. open telemetry, where I think with the tracing package in rust they get passed automatically (I think?) between spawned tasks in tokio, while in go you have to pass explicit context objects.

    If a language wants to have good subtask tracing, it would then be mandatory to use the component model's (sub)task spawning... but what if a runtime wants to do things itself for efficiency? Or what if its building blocks just don't quite map to (sub)tasks? Happy to take this somewhere else as well.

  10. added a commit that references this issue on Feb 23, 2026
  11. alexcrichton commented on Feb 23, 2026

    @alexcrichton
    CollaboratorAuthor

    Those are excellent points! That sort of stuff I would roughly imagine would happen over time if/when runtimes get more motivated to integrate component-model level abstractions and things like that. Your specific case of stack traces might be manifested in an intrinsic or two in the component model for example or something like that. This specific point would make a good issue though yeah if you'd be willing to open one here.

    For the more general problem though of mapping subtasks to guest tasks, I think we'll have to sort of see over time how things play out, what the pain points are, etc. In any case though having an issue on file to discuss this in this repo I think would be a good idea (e.g. the first use case being stack traces)

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions