Hey @Joel Dice / @Alex Crichton I'm a bit stuck and have a question about the async_sleep_post_return_caller test -- I'm a bit stuck because I don't see how we keep polling the spawned subtask (i.e. calling the callback) after the first time.
Pending event is set when the subtask first starts (on_start() which is set up during canon_lower), but the first time you check it, it's supposed to removed (set to None in the spec docs -- Waitable#get_pending_event() via WaitableSet#get_pending_event() via the Subtask itself)...
So how could the subtask keep getting wait_until'd in the main loop? I think I'm missing something here.
It's a bit hard to explain but let me try again:
2 (i.e. wait) and the waitable rep to watch is 1, the new Subtask itselfwait_until spits out { code: State.Subtask, index: 1, result: 1 }which makes sense because we have to wait for that spawned subtask, and the task is STARTED (result) -- the pending event fn was cleared for this2 (wait) w/ waitable 1 (the subtask) -- this all makes sensewait_until(), and the task will never resolve because we never re-set the pending event: # Task#wait_until -> Task#suspend_until -> Thread#suspend_until
def wait_until(self, ready_func, thread, wset, cancellable) -> EventTuple:
assert(thread in self.threads and thread.task is self)
wset.num_waiting += 1
def ready_and_has_event():
return ready_func() and wset.has_pending_event() # <-- second condition will never be the case
if not self.suspend_until(ready_and_has_event, thread, cancellable):
event = (EventCode.TASK_CANCELLED, 0, 0)
else:
event = wset.get_pending_event()
wset.num_waiting -= 1
return event
Basically, I can't see/am missing the mechanism by which pending_event would ever get set again -- obviously on_progress() would be the thing to do this, BUT it wouldn't get called from on_start() established in canon_lower (we've already started), and not on_resolve() either because we're not done yet (the caller in the subtask has not task.returned)
Cheating and taking a look at the Rust impl, it looks like wasmtime basically just do another turn at the loop by creating another subtask under the current task (so 1 + n subtasks for every poll while the spawned tasks have not completed) -- this makes intuitive sense but I cannot find rationale in the spec for this.
If I'm reading the spec right, Subtasks only get created during the initial canon_lower call (i.e. on the code side this is responding to Trampoline::StartAsyncCall IIUC), so I can't figure out which mechanism is responsible for allowing the new subtask creation.
Also @Luke Wagner in case I'm misreading the spec here
Victor Adossi said:
it looks like wasmtime basically just do another turn at the loop by creating another subtask under the current task (so 1 + n subtasks for every poll while the spawned tasks have not completed)
I'm not sure what you mean by this. Wasmtime isn't creating new subtasks on each poll or wait. All it's doing is being lazy about adding it to the caller's handle table. I.e. if the callee returns a result immediately, then there's no need to add it to the caller's handle table because, as far as the caller is concerned, the subtask is already finished.
Of course in this case the subtask will still do more stuff after it has returned, but that's invisible to the caller. And once the caller exits and the subtask still isn't done, the caller's caller (the host, in this case) will inherit the subtask. The host then allows that subtask and any transitive subtasks to complete by continuing to run the event loop until there's nothing left to do: https://github.com/bytecodealliance/wasmtime/blob/7fb8b55a8d3003a926753f4c3fcd676c813ebf98/crates/misc/component-async-tests/tests/scenario/post_return.rs#L90-L92
I'm not sure how that all matches up to the spec code, but perhaps @Luke Wagner can help with that.
Ah, so I get that the host gets to run the subtasks until there's nothing left to do, via the tick() function -- my problem is with the check in the threads that looks like this:
# Task#wait_until -> Task#suspend_until -> Thread#suspend_until
def wait_until(self, ready_func, thread, wset, cancellable) -> EventTuple:
assert(thread in self.threads and thread.task is self)
wset.num_waiting += 1
def ready_and_has_event():
return ready_func() and wset.has_pending_event() # <-- second condition will never be the case
if not self.suspend_until(ready_and_has_event, thread, cancellable):
event = (EventCode.TASK_CANCELLED, 0, 0)
else:
event = wset.get_pending_event()
wset.num_waiting -= 1
return event
My main question was how the subtask would get it's pending event set again after that first time.
The host IS currently trying to drive the subtask to completion but I'm stuck (well, the host is stuck) because it's running wait_until on a subtask that will never be ready according to that ready_and_has_event logic.
So it makes sense to me to keep running it, but maybe what you're saying is that once we're out of that loop of checking for event codes we don't enter it again? i.e. we break out of this loop:
[packed] = call_and_trap_on_throw(callee, thread, flat_args)
code,si = unpack_callback_result(packed)
while code != CallbackCode.EXIT:
thread.in_event_loop = True
inst.exclusive = False
match code:
case CallbackCode.YIELD:
event = task.yield_until(lambda: not inst.exclusive, thread, cancellable = True)
case CallbackCode.WAIT:
wset = inst.table.get(si)
trap_if(not isinstance(wset, WaitableSet))
event = task.wait_until(lambda: not inst.exclusive, thread, wset, cancellable = True)
case CallbackCode.POLL:
wset = inst.table.get(si)
trap_if(not isinstance(wset, WaitableSet))
event = task.poll_until(lambda: not inst.exclusive, thread, wset, cancellable = True)
thread.in_event_loop = False
inst.exclusive = True
event_code, p1, p2 = event
[packed] = call_and_trap_on_throw(opts.callback, thread, [event_code, p1, p2])
code,si = unpack_callback_result(packed)
task.exit()
return
And just start running the callback in a tight loop? somewhere else?
Unless I'm reading it wrong (very possible!) progressing that subtask is going through that loop until we can get to CallbackCode.EXIT, and right now I'm stuck at WAIT because of the conditions of wait_until that will only be fulfilled once, essentially.
At a high level, the host is looping until all tasks exit. In the case of this particular test, that will only happen once the host tasks representing calls to local:local/sleep#sleep-millis complete, at which point the caller of each host task will receive a returned event and exit.
Again, though, any questions about the details of the Python code will have to be addressed by Luke. I haven't studied it in detail.
So in the Rust code, from what I can see is that it:
This is fine, but if I'm correct, this means that the subtask that was reparented is now being called in Store::poll_until via do_run_concurrent() -- is that right?
Assuming what I've said above is right (minus the creating new subtasks part early on!) the thing I was getting at is that there's nowhere in the spec that it notes to break out of that callback result interpretation loop bit and "just" run the subtask to completion.
@Victor Adossi spec-wise, the way that subtasks "keep running" even after having returned their value to the caller is that that callback loop runs in a new Thread created at the end of canon_lift and the Store has a list of all threads that are waiting to run and it runs them (via Store.tick) until there are no more left (which, it sounds like, is what Wasmtime is doing too)
Victor Adossi said:
This is fine, but if I'm correct, this means that the subtask that was reparented is now being called in
Store::poll_untilviado_run_concurrent()-- is that right?
Yeah, once the subtask is inherited by the host, it and its subtasks become (or more accurately, remain) part of the general set of work-to-do stored in ConcurrentState and iterated on in poll_until
The way I could see this making sense in the current spec is if this was Thread#resume but the problem still stands that I don't see how you get out of that loop
Yup so @Luke Wagner this is what I'm doing, but my problem is how do you get out of that callback execution loop that I posted earlier -- it depends on having a pending event set on the subtask to keep executing it
The callback execution loop of a Task doesn't have any knowledge of a caller's Subtask (in fact, in the host-calls-component scenario, there is no caller Subtask)
My problem is that the spec makes it seem like the thread's work is that callback execution and result processing loop (until you get an EventCode.EXIT).
And yeah we're talking about a guest-> guest call here
the host-call to the component exits immediately basically, and a guest->guest component call lingers in the background being executed
Yes, well, the thread's work is the callback execution. The caller's Subtask isn't important; the callback execution loop just calls the on_start and on_resolve callbacks whenever it wants to and it's someone else's business of how to materialize the args and handle the results
The canon_lift for that guest->guest call dictates AFAICT that we call the callback until we get an exit
Correct
And in host->guest
Yup! So my problem is that I'm currently stuck in the wait_until branch
Because the only time we set a pending event is on-start() (the first time we start executing, that's good for ONE pass through the
And we'll never call on_resolve() because we never get to run the callback again and find out it's completed
Basically, there needs to be another call to on_progress() somewhere
as far as I can tell anyway.
Once you look at the pending event (via function) once, it's wiped -- it gets reset to None
Why isn't a Waitable in the WaitableSet passed to wait_until being set with a pending event?
That should happen independently of the callback loop that is waiting on the WaitableSet
So the pending event is set once, due to on_start() being called (and resultingly on_progress()
Ah sorry meeting! brb
It sounds like maybe you're conflating the on_progress events of a task A (which would be delivered to the supertask of A) with the on_progress events of a subtask of A. The callback loop of A can never be waiting on A's own events.
OK, meeting finished early -- I guess the first clarifying question I'd ask is what is the loop that the subtask is supposed to be in?
canon_lower sets the Subtask's callee w/ on_progress
I should say, on_progress via on_start and on_resolve
This line:
subtask.callee = callee(thread.task, on_start, on_resolve)
Does subtask.callee() just get called in a loop somewhere? The Subtask() class does not seem to drive itself AFAICT -- so what I was understanding was that the task's callback loop is what drives that to completion (I'll call this the "callback & check" loop).
The thread that is running all this is running that callback & check loop (AFAICT)
So with what I have written now, here's what happens:
Host->Guest call runs, task.returns quickly, host gets a response.
- The Thread's run loop is still going (we haven't gotten a CallbackCode.EXIT yet)
- AFTER the task.return, the AsyncStartCall for the guest->guest call starts
The initial callback call returns the code 2 (i.e. wait) and the waitable rep to watch is 1, the new Subtask itself
wait_until spits out { code: State.Subtask, index: 1, result: 1 }which makes sense because we have to wait for that spawned subtask, and the task is STARTED (result) -- the pending event fn was cleared for this2 (wait) w/ waitable 1 (the subtask) -- this all makes sensewait_until(), and the task will never resolve because we never re-set the pending event:The line subtask.callee = callee(thread.task, on_start, on_resolve) runs callee in a separate Thread so it can run its own event loop (or not... it can run synchronously or, in the future, as a stackful coroutine) and call on_start/on_resolve either synchronously or at some point in the future
So in that sequence, after 4, what I'd expect is that the subtask (later, concurrently, from its own Thread) calls on_resolve which calls on_progress which re-sets the pending event on the Subtask and thus turns the waiting event loop
OK, so that looks to be the bit I'm missing -- maybe that callee(...) like should be Store#invoke ?
Also, it's not clear that spawns a new thread that just... calls the callee forever/what the loop governing that behavior should be
AH OK, I got it:
Critically, calling a
FuncInstnever "blocks" (i.e., waits on I/O); if the callee would block, theFuncInstimmediately returns aCallobject representing the ongoing asynchronous and internally creates aThreadthat can make progress viaStore.tick.
Thanks, I think this is what I was missing -- the Subtask needs to start a completely separate Thread that calls the callee repeatedly in a loop and calls the on_start() and on_resolve() hooks.
So this line:
subtask.callee = callee(thread.task, on_start, on_resolve)
Is more like:
- Run on_start()
- Run callee in a tight loop... suspending after every one iteration?
- once callee finished, run on_resolve()
Almost: on_resolve is called somewhere during the loop, and the loop keeps running until EXIT is returned
yes -- so the on_resolve would be called after task.return got called from the guest -> guest call
I'm calling that "once callee finished"
I'd say that the task.return built-in synchronously calls on_resolve which sets a pending event on any guest caller's Waitable and also does all the lowering shenanigans (calling realloc, copying return values, etc)
OK yup, that's definitely how I understand it
One clarification as well though -- that "tight loop" is supposed to be interpreting the callee results as well, no?
How do you mean "interpreting the callee results"?
I meant similarly to the other callback and check loop -- so when in this other thread I call the callee (which is an async callback) I was thinking I should be unpacking that result
not the task.return result obviously the direct async status/code result
Ah, yes, the loop in that thread unpacks the i32 return value of the lifted callee core function and the callback function (in a loop)
Yeah, so I basically need a second version of that loop in the new Thread
Yeah, there's one logical event loop per async callback-lifted function invocation
I had something similar before, but again this gets me to the problem of what wait_until
When that new Thread gets called the first time, we call on_start() and set the pending event.
IF inside the thread we're doing the callback & check loop, we're going to get stuck, because only the first pending event will allow us to progress (the second time we enter the wait_until branch, there's no pending event).
BUT if inside that loop we're NOT doing callback and check, and we're just running the callback, then in this case enough ticks will get it to eventually call on_resolve() (the component is just waiting a set amount of time)
When that new
Threadgets called the first time, we callon_start()and set the pending event.
Technically, the Task.enter hits backpressure, it can suspend before calling on_start, such that an async caller will receive a subtask in the initial STARTING state.
So I guess what I'm getting at is -- I think the Thread that FuncInst is supposed to create anew might need it's semantics spelled out -- it doesn't seem like the semantics should be the exact same as the Task
I don't follow this "getting stuck" case...
Luke Wagner said:
When that new
Threadgets called the first time, we callon_start()and set the pending event.Technically, the
Task.enterhits backpressure, it can suspend before callingon_start, such that anasynccaller will receive a subtask in the initialSTARTINGstate.
Ah I will keep this in mind
Ah, so OK, it's this flow:
0. The initial callback call returns the code `2` (i.e. wait) and the waitable rep to watch is `1`, the new `Subtask` itself
1. `wait_until` spits out `{ code: State.Subtask, index: 1, result: 1 }`which makes sense because we have to wait for that spawned subtask, and the task is `STARTED` (`result`) -- the pending event fn was cleared for this
2. The callback is run a second time, and returns the unpacked code `2` (wait) w/ waitable `1` (the subtask) -- this all makes sense
3. We get into the *second* `wait_until()`, and the task will never resolve because we never re-set the pending event:
The semantics is spelled out by canon_lift: in guest-to-guest calls, callee is a curried canon_lift closure
I don't see why you're saying in step 3 that the pending event is never re-set... it gets re-set when on_resolve gets called by the subtask (at some point in time in the future)
Yup, that would make sense, and that is what makes me think that we need a similar "callback & check" loop.
The problem is, when I do the callback and check loop, I fall into the situation above.
It seemed to me like wasmtime was just running the callback (I assume I am wrong here) until it completed, but I haven't seen exactly what they do for this case other than just calling the callback repeatedly/polling the future until it's done.
Luke Wagner said:
I don't see why you're saying in step 3 that the pending event is never re-set... it gets re-set when
on_resolvegets called by the subtask (at some point in time in the future)
Hmmn, do you have time to get on a quick call? that might be higher bandwidth!
(FWIW, I assume we're talking in the spec/Python terms, not how you're implementing this in JS)
Yeah, you bet
one sec...
Last updated: Dec 06 2025 at 06:05 UTC