Hi, is it too early to start adding support for async and future in the c# code gen ? I see there are tests already in the main branch?
No, not too early -- now is a great time to get started. I was planning to work on it myself but haven't had a chance yet. Happy to answer any ABI-related questions if you're ready to take a stab at it.
Thanks I'll make a start.
@Joel Dice hi, might take me a few questions to get in the right space with this. For
https://github.com/bytecodealliance/wit-bindgen/blob/454d6885b5b54adb3601a9b80ca9ff64f6ed7b8d/tests/codegen/futures.wit#L6
We have a function that takes a future as a parameter. The import of this function sounds like it will in c#, take a Task and lower it to an i32. But what can the receiver of this i32 do with it? I would expect that it could await the future, but how would it do that?
For this single import I think I also need to provide a couple of extra functions , waitable-set-wait, that I must call before the calling the import, and provide a future-poll that the runtime can call to check the state of the future, is that sort of right?
Think I've confused future and async I will start with just async as that looks simpler
Sorry for the late reply -- I was out on vacation for a few days.
To call an imported function that takes a future, the guest needs to first call future.new, which returns a pair of i32 handles -- one representing the writable end (roughly equivalent to a TaskCompletionSource) and the other representing the readable end (roughly equivalent to the Task corresponding to the TaskCompletionSource). Then it can pass the readable end to the function and later write a value to the writable end when such a value is available.
In the Rust bindings, we represent the writable end as a FutureWriter and the readable end as a FutureReader, which implements the std::future::Future trait, allowing it to be awaited.
Anyway, I agree that you'll probably want to start with just supporting the async import and export ABIs before moving on to future and stream support. Note that a call to an async-lowered import will either return a result immediately or return BLOCKED with a subtask handle representing the status of the call. That handle may be added to a waitable-set, which in turn may be waited on using either waitable-set.wait or by returning CALLBACK_CODE_WAIT from the async-lifted-with-callback export function that the host originally called the guest on. The latter is generally preferable when using the callback-based ABI, since it allows the host to make other concurrent calls to the guest.
For every async function we generate a [async-lift].... and a [callback][async-lift] for those 2 scenarios you describe ?
on the export side I'm looking at first
Yes, and the signature of the [async-lift]... function will take the same set of parameters the sync version would, but return an i32 representing the "callback code". The [callback][async-lift]... function will take 3 i32 parameters (the first is the event type, and the meaning of the rest depends on the event type) and return an i32 callback code. Either the [async-lift]... or the [callback][async-lift]... function will return its value to the caller by calling the task.return function, whose signature depends on the WIT-level return type of the function being exported.
One way to get a feel for all this is to examine the output of the C bindings generator for various async-lifted exports.
Thanks very much, I should be able to get the first tests passing from here.
are you using a locally built clang to get support for async in wit ?
Ive got
C:\github\wit-bindgen\target\artifacts\simple-import-params-results\test-csharp\bindings>"c:\github\wasi-sdk25/bin/clang" @obj\Debug\net9.0\wasi-wasm\native\link.rsp "
error: unable to add component type "TestWorld_component_type.wit"
Caused by:
0: expected keyword `func`, found an identifier
--> TestWorld_component_type.wit:4:17
|
4 | one-argument: async func(x: u32);
| ^
clang: error: linker command failed with exit code 1 (use -v to see invocation)
I believe clang is calling wasm-component-ld, which is producing that error. WASI-SDK's version of wasm-component-ld isn't new enough to understand async, so you'll want to replace the one in WASI-SDK with the latest release (which isn't using the very latest wasm-tools, but it's only one version behind, so should be new enough).
I've been using wasm-tools directly to convert modules to components and/or embed component type custom sections, so I haven't tried using wasm-component-ld yet -- hopefully it should just work.
lets see :-)
that links at least. What is the significance of [export] in this c
__attribute__((__import_module__("[export]a:b/i"), __import_name__("[task-return][async]one-argument")))
I understand that this is the callback (task-return) for returning the async result, so it needs some specially handling by the linker I guess.
yeah, that's just the convention we made up and which wit-component recognizes as the import module to expect the task-return function to be imported from. We use the [export] prefix to indicate that, although this is an import, we're doing the import on behalf of a specific exported function.
thanks, this can all wait until Monday, I'm just asking today. I have this error
------ Failure: simple-import-params-results --------
component: test
path: tests\runtime-async\async\simple-import-params-results\test.cs
error: failed to compile component "tests\\runtime-async\\async\\simple-import-params-results\\test.cs"
Caused by:
0: compiler produced invalid wasm file "C:\\github\\wit-bindgen\\.\\target\\artifacts\\simple-import-params-results\\test-csharp.wasm"
1: lowered result types `[]` do not match result types `[I32]` of core function 140 (at offset 0xfdd769)
1 tests FAILED
Which seems like I've got the [async-lower] or [async-lift] wrong, but they are both int32 (int32) for a void async that takes an int32 which I think is right and the same as the c code gen. Is there any modification of signatures that goes on wasm-component-ld or wasmtime ?
its a bit weird becuse function 140 is unrelatead
(func $"Thread::ResetCachedTransitionFrame()" (;140;) (type 0) (param i32)
oh, Im reading the alias wrong, but I still don't understand how this works. The core function for this needs to return the callback code so it will have a result type of i32. Is it the canon lift on the alias that is incorrect perhaps
(type (;49;) (func (param "x" u32)))
(alias core export 7 "[async-lift]a:b/i#[async]one-argument" (core func (;140;)))
(func (;41;) (type 49) (canon lift (core func 140) async))
(component (;0;)
(type (;0;) (func (param "x" u32)))
(import "import-func-one-argument" (func (;0;) (type 0)))
(type (;1;) (func (param "x" u32)))
(export (;1;) "[async]one-argument" (func 0) (func (type 1)))
)
(instance (;20;) (instantiate 0
(with "import-func-one-argument" (func 41))
)
)
I' m going to dig further into wasm-component-ld as I suspect something there currently. I'm probably wrong, but need to see if what you do , generating the sections manually, fixes things
If you can point me to a branch with your changes and instructions for reproducing the issue, I can take a look.
Thanks very much, I wonder if its because I've got 2 wits in the link, haven't investigated that yet.
https://github.com/yowl/cs-wit-bindgen/tree/csharp-async-simple
Download wasi sdk 25 and replace wasm-component-ld, Im using a locally built one from main yesterday.
Then cargo build
and
cargo run test --languages csharp tests\runtime-async --artifacts .\target\artifacts --rust-wit-bindgen-path .\crates\guest-rust --runner "wasmtime -W component-model-async"
Thanks. I've got a bunch of meetings this morning, but I'll dig in later today.
I'm not totally stuck yet, I can do more research later if you have better things to do.
Okay, I reproduced this on my end. It looks like wit-component is getting confused and generating an async lift without a callback because the core module is not exporting a callback function. It should be rejecting the module when it sees that you're using the with-callback ABI but not providing a callback, but instead it produces a component that fails validation. I'll fix the wit-component issue so we can catch the problem there (and hopefully provide a clearer diagnostic).
Meanwhile, maybe you can dig into why the generated C# code is not exporting a callback function.
Ah thanks, right, I haven't implemented that bit yet as I don't think it will be on the call path yet. But definitely I need to do it, so might as well be now
Great, that seesm to be passing the first test. Thanks very much.
WIth an async function that returns a value, not void, I understand that it takes an extra pointer parameter is passed . How does that relate to the task-return or the pointer is nothing to do with the return value, but is just used by the runtime? What should this pointer point to ?
Are you referring to the async lower ABI? If so, yes, the core signature of an async lowered function will include an extra parameter if the component-level signature returns a value, and that must point to an allocation in linear memory which is aligned and sized correctly for the result to be stored. task-return isn't relevant here because we're talking about lowering imports, not lifting exports.
The async lift ABI has no such result pointer parameter, since task-return is used to return the result, if any. The main thing to keep in mind is that the lift and lower ABIs are different.
Thank you!
I see the codegen tests use the waitable-... way of returning , is the [task-return]flow well tested ?
ignore that, I see the c tests use it.
I have a question about the c test for simple-future. There are 2 functions in the wit
https://github.com/bytecodealliance/wit-bindgen/blob/eaf42f6c12d9cca8231d5a361d20b6c21c2a2739/tests/runtime-async/async/simple-future/test.wit#L4-L5
Looking at the test for drop-future where it writes to the writer:
https://github.com/bytecodealliance/wit-bindgen/blob/eaf42f6c12d9cca8231d5a361d20b6c21c2a2739/tests/runtime-async/async/simple-future/runner.c#L34
It calls test_future_void_write which is generated as
runner_waitable_status_t test_future_void_write(test_future_void_writer_t writer) {
return test_future_void__write(writer, NULL);
}
which in turn calls test_future_void__write defined as
__attribute__((__import_module__("my:test/i"), __import_name__("[async-lower][future-write-0][async]read-future")))
extern uint32_t test_future_void__write(uint32_t, const uint8_t*);
Which has the import name for the read-future function. What am I missing here?
I.e., I would expect to see an import for drop-future.
Is it that internally any "void future" would work here, due to some internal things. I think I read that only one task (subtask?) can be active at a time?
I'm not sure I understand what you're asking, but note that we use a "trick" to refer to a given future or stream type in the binding generator, allowing wit-component to determine which type to use when generating references to the future.write, future.drop, etc. intrinsics: https://github.com/bytecodealliance/wasm-tools/blob/f6967282dce8f2b3be29ddcd0f33cb90a37ec8f4/crates/wit-parser/src/lib.rs#L1160-L1174
Trying to encode the entire type using only e.g. name mangling would be difficult if not impossible (imagine mangling future<list<tuple<some-record, some-resource-type>>>, especially given that resource types are not necessarily uniquely identified by their names, e.g. when a resource is both imported and exported), so we instead refer to some arbitrary function that uses the type we care about and use an index to indicate which one we mean.
I'm curious about read-future in the name of the import used by the drop-future test. Reading the link you posted, and your explanation, I think I get it. The type is the same, so the name at the end of the import is not that important, its the type that must be matched.
Yeah, the point is to pick a function that refers to the type -- if there is more than one which refers to that type, then the binding generator picks one arbitrarily and uses it.
I'm thinking to add the cancel functionality next. We can't extend Task to hold the handle as its sealed, so i was thinking we maintain a list of some sort of created async function tasks, so that we can map the task back to the handle. We could then have an extensions perhaps to Task, e.g. Cancel, that would look up the handle from the hash set of Tasks and call cancel on that handle. Does that sound sensible?
Personally, I'd recommend leaving cancellation until later. I haven't even added cancel support to Python or Go yet; I'm basically waiting until someone asks for it. I'd suggest finishing everything except cancellation first, at which point developers will be able to start doing interesting things with WASIp3 in .NET.
Anyway, whenever you do tackle cancellation, the hash set sounds plausible to me; I'd be interested to here what Pavel thinks. I'd have to go back and study Task again to have more of an opinion.
Ok, I will leave it for now and look at a different runtime
test to tackle.
Joel Dice said:
I'd be interested to here what Pavel thinks. I'd have to go back and study
Taskagain to have more of an opinion.
I'm afraid I would have to spent some time playing with latest p3 before I can offer more valuable insight. Right now I think :
I'm happy to add GC Collects in places to see what breaks in the runtime tests
Pavel Šavara said:
- Are there delagates/functions/callbacks as call parameter to be marshaled in p3 ? Those also have GC story. In C# they could also capture a closure and keep alive other objects.
I don't have a direct answer to this, but I'll note that we've tried to make WASIp3 "less trappy" in that we try to avoid parent/child relationships between component model handles in general. In p2, we had a lot of parent/child relationships such that if you dropped the parent handle before any of the children, you'd trap, which meant GC-based languages had to do extra work to ensure handles were dropped in the correct order. p3 is a lot more relaxed about that, FWIW.
Pavel Šavara said:
- I'm worried that WASI streams could be resolved multiple times. Specifically I don't want to hack and re-resolve C# Task.
Yeah, I don't think a stream should be represented as a Task. Instead, each call to Read or Write should return a new Task, which is equivalent to how the Rust and Python bindings generators work.
Pavel Šavara said:
- I'm prepared to change my opinion after we have multi-threading, in which we could maybe overcome sync-over-async on dotnet side and we could do re-entrance as another guest thread (or something).
It's looking like we might have cooperative multi-threading as part of 0.3.0, or if not, soon afterward. And it's already implemented in Wasmtime, so I think it would be reasonable for the .NET port to just use mult-threading from the beginning.
Pavel Šavara said:
- I don't know what's the current state of cooperative multi-threading. Is there some demo/presentation/lecture/video I can watch ?
This is the most up-to-date resource I'm aware of: https://github.com/WebAssembly/component-model/blob/main/design/mvp/Concurrency.md. You can also read about the thread.* intrinsics starting here for a summary of how threads can be created, suspended, and resumed.
Regards this wit
export run: async func();
which is from a test runner. Will the runtime try to call a [callback] function, and if it does, what would it pass for the waitable, second parameter?
It will call the callback function if the run export returns CALLBACK_CODE_YIELD or CALLBACK_CODE_WAIT. If it's the former, the waitable will be zero (i.e. not applicable). If it's the latter, the waitable will be whichever waitable the event concerns, i.e. one of the waitables in the waitable-set returned as part of the CALLBACK_CODE_WAIT return value.
If the run export returns CALLBACK_CODE_EXIT, then the [callback] function won't be called (at least not for that particular task).
Thanks, that makes sense. I'm trying to work out how I should handle
https://github.com/bytecodealliance/wit-bindgen/blob/90b50130ee83f298581e23dffc916ff36c8fd8a3/tests/runtime-async/async/pending-import/test.c#L34
As you know this drop is part of the vtable, but I didn't really want to expose the vtable to the user code, i.e. the test code, so I though I could capture the correct vtable in the event, and then and a method to the event class to "drop the waitable", e.g.
public static unsafe int PendingImportCallback(AsyncSupport.Event ev)
Then inside this method:
ev.DropWaitable();
However if I do this in the export for the exported async method in the test code, the runner code, which exports a different method, the run, it also ends up with that export
[global::System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute(EntryPoint = "[callback][async-lift]run")]
public static int RunCallback(int eventRaw, int waitable, int code)
{
AsyncSupport.Event e = new AsyncSupport.Event(eventRaw, waitable, code, FutureVTable);
return RunnerWorldExportsImpl.RunCallback(e);
}
And of course I have no future vtable here.
Maybe I should capture in the function bindgen, if I have used a future, and then I can write the callbackinterop appropriately.
Its also possible, that this will be more obvious to me, when I get to a more sophisticated wit.
Let me think again about this, I may have a better question later.
Sorry, in a meeting now, but will chime in when I can
Ok, thanks, I was just thinking about how my current scheme doesn't work when the export has multiple future params of different types.
Generally speaking, before returning CALLBACK_CODE_WAIT the bindings generator or associated runtime library should store an entry in task-local map representing the pair of the waitable and e.g. a closure or equivalent representing what to do when an event arrives for that waitable. The key thing to note is that "task-local" means saved and restored using the context.set and context.get intrinsics, respectively.
Don't hesitate to look at what the Rust, Python, or Go bindings generators and their runtimes do.
There is also a rust example for this test, I will look at what it generates here, thanks
Ah, now I see something I tried to kick down the road, that is likely useful. I just have a single context type. I did notice that the c code created one for the function, but it didn't dawn on me that I could customise the one of the fields
One thing that throws me a bit here, is that the rust test here is just
impl crate::exports::my::test::i::Guest for Component {
async fn pending_import(x: FutureReader<()>) {
x.await
}
}
Whereas the c is way longer . Perhaps I should not be looking at the 'c' to get an idea of what should be in the user code, but just for the final sequence of events?
That's because the C generator is ultra-minimal, leaving the details to the application developer. The Rust, Python, and Go generators and their runtime libraries do a lot more, handling the details on behalf of the app developer. For Rust, see async_support.rs and related code under the rt module, which is where those details are handled.
You can also generate Rust bindings using the wit-bindgen rust subcommand to see how the generator bridges the application code with the ABI, using the rt library.
THanks, I will reset my thinking a bit here.
Hi, I'm on the way to making the C# bindings more like Go and Rust. Still on the pending-inport example
package my:test;
interface i {
pending-import: async func(x: future);
}
world test {
export i;
}
When the runner writes to the future x, that completes successfully, but the runtime should resume the future read in the test side, as I understand it. But I'm missing how that happens as there doesn't seem to be an export that is called in the test, should I have registered a callback or something before returning CallbackCode.Yield; ?
Perhaps Im missing a waitable join somewhere..
yes, that might be it, let me try that.
Actually still I bit confused, I can't see where the rust is doing that, and there is no go implementation, except for the generated code which has
wit_async.SubtaskWait(uint32(wasm_import_pending_import((x).TakeHandle())))
Which does do a join in SubtaskWait but it doesn't seem to leave space to do the writing to the future, so not sure how that works....
I 've added a join which I think was the missing bit, was trying to get away without implementing the [callback] part so looking at that bit now.
I'm getting this error at the end of the test execution after calling the callback on the test side
wasm trap: async-lifted export failed to produce a result
The callback I have for [callback][async-lift]my:test/i#pending-import is returning 0 which I thought was CALLBACK_CODE_EXIT, is that not right ?
ah, I 've not called task-return. Sorry for spanming
@Scott Waye you spam all you want. It helps to see your thinking process -- it shows what we need to explain better and/or modify. If you can't figure it out, how can others? :-)
Lol, I do find writing things down sometimes kicks the brain into doing something useful. I need a chat client that just go > /dev/null :-)
About the implementation of the Stream Write/StartWrite method, the buffer is passed, but who is responsible for freeing that, is it the reader?
I say freeing, but could be unpinning in C#
The reader will normally be a different component from the writer (or the host), so it wouldn't be able to free the buffer even if it wanted to. Instead, the writer is responsible for freeing or unpinning the write buffer once the write completes (i.e. after it either completes immediately without delay or after an EVENT_STREAM_WRITE is received for that write). Same goes for the read end: the component that's reading must ensure the read buffer stays valid (and doesn't move) until it has been notified either via a return code or an event that the write has been completed (or cancelled).
EVENT_STREAM_WRITE gives the buffer in the args i suppose.
I might be able to kick this bit down the road I suppose, with a TODO
Hmm, I guess it depends on what, precisely, you plan to kick down the road. It's fine to pin the buffer longer than necessary, but if you pin it shorter than necessary you'll end up with hard-to-debug heap corruption sooner or later.
That is true, I was thinking to pin it, and leave it there. But if the EVENT_STREAM_WRITE gets the buffer, then it would be trivial to clean up
Looking at the Go
case EVENT_STREAM_READ, EVENT_STREAM_WRITE, EVENT_FUTURE_READ, EVENT_FUTURE_WRITE:
waitableJoin(event1, 0)
channel := state.pending[event1]
delete(state.pending, event1)
channel <- event2
I suppose delete would be the thing to look at, if I can find it.
That just removes an entry from the state.pending map; it doesn't affect pinning. Unpinning the read buffer for Go happens via this defer statement
That works because wit_async.FutureOrStreamWait (called later in the same function) blocks the calling goroutine until the read completes, at which point the buffer can be unpinned.
Think I understand that code, once the lifting is complete the read buffer can go. I suppose write is similar.
thanks
Technically, we could unpin as soon as wit_async.FutureOrStreamWait returns and before lifting. Unpinning just means the GC is free to move the buffer, but that won't harm pure Go code. The pinning is just to ensure the pointer we passed to the host remains valid, and thus is no longer needed once the host tells us the read has completed.
Ah yes, of course.
Not sure what I've done, but I have this error
---- simple-stream-payload | runner.cs | test.cs ----
runner: .\tests\runtime-async\async\simple-stream-payload\runner.cs
compiled runner: C:\github\wit-bindgen\.\target\artifacts\simple-stream-payload\simple-stream-payload\runner-csharp.wasm
error: failed to run simple-stream-payload
Caused by:
0: failed to compose "C:\\github\\wit-bindgen\\.\\target\\artifacts\\simple-stream-payload\\simple-stream-payload\\runner-csharp.wasm" with "C:\\github\\wit-bindgen\\.\\target\\artifacts\\simple-stream-payload\\simple-stream-payload\\test-csharp.wasm"
1: no dependencies of component C:\github\wit-bindgen\.\target\artifacts\simple-stream-payload\simple-stream-payload\runner-csharp.wasm were found
What dependencies is it looking for, a particular import?
Think I've missed some code in the runner, will check that.
ah yeah that error means that the runner doesn't actually import the test component, so it might be that the runner is stubbed out or not importing anything?
Yep, if I don't actually use it the NAOT compiler will trim the import
In the runtime tests, the runner exports a run method, and I have this error
(base) PS C:\github\wit-bindgen> wasmtime -W component-model-async C:\github\wit-bindgen\target\artifacts\simple-future\composed-runner.cs-test.cs.wasm
Error: failed to run main module `C:\github\wit-bindgen\target\artifacts\simple-future\composed-runner.cs-test.cs.wasm`
Caused by:
no exported instance named `wasi:cli/run@0.2.6`
My export is [async-lift]run" which I think used to be correct. Has something changed - something now expects the cli run export ?
The wasmtime CLI defaults to the run command, which by default expects the component input to export wasi:cli/run. However, the runner tests in wit-bindgen now export a top-level run function, which you can invoke using wasmtime run --invoke='run()' foo.wasm
ah, that explains how CI is passing, thanks
With this test https://github.com/bytecodealliance/wit-bindgen/blob/350e8ad7abe8f65357c8984535d7bca5b9882c2c/tests/runtime-async/async/simple-stream-payload/runner.rs , would you expect the first write to be blocked (and need to return to the event loop before getting its count back?
Possibly; it depends on the details of how future::join! is implemented and how the read_stream function in the composed component is implemented. If the read_stream function is called first and initiates a read immediately, then the write will finish as soon as it is called. I.e. if the read happens first it will return BLOCKED and the write will complete immediately, but if the write happens first then it will return BLOCKED and the read will complete immediately.
I see, in my case the write will happen first so the BLOCK is expected. In which case the mechanism is for the runner to yield I think?
This seems to happen in go with the reading from the channel, but I can't see exactly what that does in terms of the low level ABI calls, is it pollable.poll or there is a callback ?
Are you with Akamai now ?
Scott Waye said:
I see, in my case the write will happen first so the BLOCK is expected. In which case the mechanism is for the runner to yield I think?
It will return CALLBACK_CODE_WAIT with a waitable-set containing the stream handle. That's true for both Rust (because the "stackless" concurrency model using a callback for async exports matches its async/await model) and Go (because even though it has "stackful" goroutines, those are currently implemented by the Go compiler using a CPS transform that effectively makes them "stackless").
Scott Waye said:
Are you with Akamai now ?
Yes!
I'm missing something, or looking in the wrong place. https://github.com/bytecodealliance/wit-bindgen/blob/350e8ad7abe8f65357c8984535d7bca5b9882c2c/crates/go/src/package/wit_async/wit_async.go#L187C6-L187C24 catches RETURN_CODE_BLOCKED and creates the waitable set and does the join. Then after the write is unblocked, it enters here https://github.com/bytecodealliance/wit-bindgen/blob/350e8ad7abe8f65357c8984535d7bca5b9882c2c/crates/go/src/package/wit_async/wit_async.go#L120 doesnt it?
I'm in a meeting now; but will take a look when I have a chance.
If you prefer we can cover this in the next meeting, which I think is in 2 weeks?
In FutureOrStreamWait, the code = (<-channel) line blocks the current goroutine, which unwinds the goroutine stack and passes control to the Go scheduler. If there are no other runnable goroutines, then the "root" goroutine resumes here, and that returns control to the host here.
Go is its own special beast; to really understand what's going on you have to read this and study the Wasm code it generates. I don't recommend you do that, though. Just imagine FutureOrStreamWait is an async function in C# that awaits a Task representing the future or stream and returns control to the top level event loop in the meantime.
Thanks, an async Task is where I want to get to. I can create that Task using a TaskCompletionSource and store it somewhere as go does. Then I will need to complete that task at some point, in go that would be from Callback I think, where is Callback hooked up? https://github.com/bytecodealliance/wit-bindgen/blob/350e8ad7abe8f65357c8984535d7bca5b9882c2c/crates/go/src/package/wit_async/wit_async.go#L61
Oh I see it
Its a [callback] export
Awesome, I can play some more, thanks a lot.
Hi, made a little progress, but have another question, if I return CALLBACK_CODE_WAIT out of the async run in the runner, how do I run the event loop, or is that handled by wasmtime?
Returning CALLBACK_CODE_WAIT tells the host: "wait until one of the waitables in this waitable-set has an event and then deliver that event to my callback function". During that wait, no guest code is run for that task -- it's entirely in the host's hands at that point.
In other words, you should _not_ return CALLBACK_CODE_WAIT to the host until you've run out of things to do in the guest (i.e. there's nothing you can do until one of the pending waitables has an event)
public static async Task Run()
{
var (rx, tx) = IIImports.StreamNewByte();
async Task Test()
{
var writtenOne = await tx.Write([0]);
}
AsyncSupport.Join(Test(), IIImports.ReadStream(rx));
}
Taking this simplified runner, if Write blocks, AsyncSupport.Join should loop calling [waitable-set-poll] or return and Run returnsCALLBACK_CODE_WAIT ?
Like Run is marked async but if Join waits for its Tasks to finish in a busy loop, then there is nothing async about it, so I suppose it needs to return CALLBACK_CODE_WAIT
So I think that fits with your statement "run ouf of things to do", its the last statement in the function after all.
you must be fed up with me by now :-)
\
Somewhere there should be a function that returns a uint32; it should run a task, and if that task hasn't completed yet, it should collect all the waitables created by that task into a waitable-set and return CALLBACK_CODE_WAIT. It's been long enough since I worked on .NET that I can't remember what "run a task" looks like, so apologies for being vague about that.
if there aren't any waitables, then it could just return CALLBACK_CODE_YIELD, meaning "I, the guest, don't have anything I'm waiting on, but I'm in the middle of a compute-intensive operation and want to give other tasks a chance to run because I'm a good citizen; call my callback after you've let other tasks run and I'll continue"
it might help to do a zoom call at some point and walk through this together
thanks, let me try a few things first based on the above. I feel it is very close, just might need somethings moving around.
BTW, to clarify what I said above: if there are waitables, you still might want to return CALLBACK_CODE_YIELD instead of CALLBACK_CODE_WAIT and also call waitable-set.poll, which says "I've got some waitables I'm interested in, but I also have stuff to do in the meantime, so give other tasks a chance to run and let me know if any of those waitables are ready, but otherwise let me run some more". You can see that logic for Rust here
In pseudo-code:
if I have more work to do, regardless of any pending waitables:
if I have pending waitables:
event = call waitable-set.poll(my-waitable-set)
if event non nil:
deliver(event)
continue
return CALLBACK_CODE_YIELD
else:
return CALLBACK_CODE_WAIT | (my-waitable-set << 4)
Last updated: Feb 24 2026 at 05:28 UTC