[0.3.0-draft]: Move away from streams as an API?
I'd like to propose a possible radical restructuring of stdin/stdout/stderr and how they're modeled for 0.3.0. Specifically something like this for 0.3.0:
interface stdin {
read: async func(amount: u32) -> result<list<u8>>;
}
interface stdout {
write: func(data: list<u8>) -> result;
}
interface stderr {
write: func(data: list<u8>) -> result;
}
Specifically this would do away with streams entirely and instead focus on just bytes. The stdin interface is tagged as async indicating that it will block waiting for input and bindings generators might want to do future-y things, but they can of course opt-out of that as well. For write and stdout/stderr they're not tagged as async and semantically they block the program while the write is happening.
This is naturally very different from both 0.2.0 and the current 0.3.0-draft, so I'll try to motivate why I'd propose this design instead:
- At the OS level stdout/stderr are not async. AFAIK it's just not possible on Windows and on Unix no one does it as turning your end nonblocking automatically turns the other end nonblocking which is basically never expected nor what you want. That means that as an "abstraction over what platforms provide" pretending output is async is already a bit of a lie.
- Printing to stdout/stderr is quite common but also low-level at the same time. Every language has some facility for printing to the screen and printing to stdout/stderr. No language I'm aware of exposes this as an async interface or as a stream. What I'm proposing here I feel better matches source languages where "just print the stuff before I keep going" is what's desired with these interfaces most of the time.
- Historically when implementing 0.2.0 and what I'm forseeing with 0.3.0 interfaces is a lot of tricky questions. With 0.2.0 we went back-and-forth about what to do about stdout/stderr and how to implement them in Wasmtime. In the end we skipped the async part of streams entirely and the native implementations just block and are "always ready". I'll note stdin is a bit special but I don't think we can get away from that, so this is mostly 0.3.0. I see https://github.com/WebAssembly/wasi-cli/issues/64 as well for 0.3.0 and I also feel like it's asking a lot to have so much extra runtime support code just to write to stdout/stderr in language standard libraries. Overall I've always had the feeling that stdout/stderr management is causing a lot of headaches and integration questions when at the end of the day everyone typically wants something much simpler that's along the lines of "please just print this".
IIRC @pchickey and I basically concluded during 0.2.0 that we'd just come back to this at some point before 1.0.0 and rethink stdio. Personally I think now's a good as time as any as we transition from 0.2.0 to 0.3.0. There's of course downsides to the above such as "piping" is less obvious than before or redirection, but so far I'm not aware of any guest language which would support that. This could hypothetically be added in the future but I think it'd be best to start with a simple write and read function if we can.
One other thing I can mention: from my time working on Rust we at the time discovered that there's no way to portably write bytes across platforms to stdout/stderr. Fundamentally on Windows at the time it was UCS-2 which is not what most source languages expect (they just write bytes). In Rust that means that writing to stdout on Windows implicitly converts all bytes to &str (e.g. UTF-8) and then reencodes to UTF-16 and then does the write. Rust however still exposes the "just write bytes" primitive from the standard library.
For WASI we may want to consider changing stdout/stderr/stdin from being byte-based to being string-based, which would also necessitate a change from stream<u8>. I'm not certain this is the right thing to do though since so much of the world assumes stdio is all byte-based, so it doesn't seem unreasonable to have component runtimes on Windows handle the difference but no one else does.
note that nodejs has functions for async writes to stdout/stderr.
also, on unix it's common for commands to write binary to stdout, e.g. writing a raw gzip file to stdout:
jq < my/json-file.json | gzip > pretty.json.gz
I'd have to double-check, but my hunch is that the async read/write in Node.js is a "lie" in the sense that it goes out to a thread pool and it's blocking there. If things haven't changed from ~10 years ago (which I realize that's a little far-fetched) Node is built on libuv and Rust also used to use libuv. I remember libuv specifically treating stdio and not turning file descrptors nonblocking and farming reads/writes out to a thread pool.
Also I understand that commands are often piped to one another, that's done and works on Windows too! The problem is writing to the console on Windows. If things haven't changed from whence I last looked, there's no way to write binary data to a console on Windows, it's required to be a UCS-2 (aka a sequence of 16-bit integers, aka UTF-16 often reencoded from UTF-8). Note that this is different from processes piping output between each other, that's what enables piping binary data between processes.
I agree with the spirit of this change! Using streams for stdin/out/err is much more complicated than the reality, which is that programs need to print bytes to the terminal so humans understand what happened. Unix pipes should be subsumed by other modes of component composition: we no longer want to use stdin/out/err to communicate between components, just from components to humans.
For WASI we may want to consider changing stdout/stderr/stdin from being byte-based to being string-based, which would also necessitate a change from stream
This may end up being too radical, but I think its worth considering if there's some point in the design space that could accommodate this the concern that "stdin/out/err are just for communicating with humans" and also that "those humans may be using a tty" and so colors and other control codes are expected to work. WASI 0.2 does expose isatty (in a roundabout manner that never got fleshed out). Can we get an interface as trivial as fn write(data: string) -> result; in the case where the program knows it just wont be using tty functionality? I don't think wasi-libc could target that, but maybe others could.
I talked some more about this yesterday with @dicej, @yoshuawuyts, and @vados-cosmonic, and wanted to write down some thoughts from that. Overall they helped me clarify what I'm trying to do here, which is two-fold:
- Simplify "hello world" and small applications. In these situations the full power of streams is not necessary, both today with WASIp2 and tomorrow with WASIp3. It's kind of nice to be able to get started quickly with "just write the bytes" style of API.
- Change the defaults that languages expect to synchronous bindings, not asynchronous bindings.
The first point is relatively self-explanatory where a "simple" function is simpler than streams, but the second point is relatively nuanced. Namely with the component model async situation async is not part of a function's type, meaning there are not "colors" in component model async. Thus it's possible to call any function asynchronously just as well as calling it synchronously. This means that regardless of annotations in WIT it's actually possible to still use these functions in a synchronous or asynchronous manner.
Given that I don't actually think that there's any loss in functionality over what we have today with WASIp2. The main change is the default expectation of what these APIs are doing. For me this would be a signal that the default expectation should be the use of synchronous APIs for writing and asynchronous for reading. Scenarios which require async, though, are still possible! For example one particular library could virtualize stdio with asynchronous interfaces and then if a language imported everything as asynchronous then everything would cooperate and work well together.
For WASI we may want to consider changing stdout/stderr/stdin from being byte-based to being string-based, which would also necessitate a change from stream
This may end up being too radical ...
No I think you're right, I don't think it's worth going down a string-based route.
The proposed changes make sense to me too for the upcoming 0.3.0 release.
Longer term (in some later 0.3.x release leading up to 1.0-rc), I was thinking we might want to do the following and, since Alex mentioned thinking about 1.0 above, I'd be curious what folks thought.
We could introduce a new wasi:cli/main interface:
interface main {
main: async func(args: list<string>, stdin: stream<u8>) -> stream<u8, _, error-code>;
}
and it would be the linker's injected start-thunk's job to set up a standard (synchronous) C/C++ main() call, just like it does today, but replacing the calls to wasi:cli/{stdin,stdout} with the stdin parameter of main and, for stdout, calling stream.new (and immediately task.returning it).
The point of this change would be that any language could create Unix-style pipelines as easily as in bash. E.g., in JS, something like this could work:
import { main as f1 } from './f1.wasm';
import { main as f2 } from './f2.wasm';
import { main as f3 } from './f3.wasm';
let result = f1(args1, f2(args2, f3(args3, (await fetch('data.json').body)));
which I think would make wasi:cli components more-generally useful, usable and composable.
With wasi:cli/main, I think we could @deprecate (keeping around throughout 0.3.x) wasi:cli/run, wasi:cli/stdin and wasi:cli/stdout.
stderr is missing from the above because, for it, I agree with Alex's comment about it being a simple synchronous function. Separately, I think there are a number of good reasons (perf and DX) to add a canon console.log built-in that is omnipresent and not considered part of the official I/O of the component (such that console.log can be non-deterministically ignored, as is standard in production environments today, saving the runtime cost of the WIT-level copy in such cases), and this would @deprecate wasi:cli/stderr too.
WDYT?
i was just discussing SIGPIPE issues on another project, so sounds good to me, assuming you can get good handling for what to do when the read end of stdout is closed. you'd want to be able to run cleanup (e.g. finishing database transactions), but exit the component asap, in particular aborting any infinite write-output loops.
I have no stake in this game and either solution will probably work fine, but since you were openly solliciting feedback on Zulip, here it goes: :smile:
I'm not sure I fully understand the need to change the interface. The CM is "colorblind" so regardless of the WIT syntax, it can be called synchronously and asynchronously, and implementations are going to need to deal with that anyway, right?
At the OS level stdout/stderr are not async (...) pretending output is async is a bit of a lie.
I see your point, but the same used to apply to File I/O, until io_uring came along. As matter of fact, the first (and only) example on https://man7.org/linux/man-pages/man7/io_uring.7.html is about asynchronously piping stdin to stdout.
I think part of the problem is that the usage of stdin/out/err is too overloaded.
- For the common case its just a text-based output/UI mechanism, limited only by the rate the terminal window is able render characters on screen. (CPU-bound)
- In other scenarios they're proper binary inputs/outputs and can be piped into anything, including files & sockets. (IO-bound)
I like Luke's idea of having a dedicated logging mechanism. I think that also plays into your point regarding:
Simplify "hello world" and small applications. In these situations the full power of streams is not necessary (...)
With that in place, I'd say:
- Stdin/out: asynchronous binary
stream<u8> - Logging: synchronous &
string-based
And in some environments, the logs are redirected to stdout/err.
No language I'm aware of exposes this as an async interface or as a stream.
Aside from Node.JS, .NET's Console.In/Out/Error properties are TextWriters/Readers wrapping a stream and expose *Async versions of their operations. Example: await Console.Out.WriteAsync("Hello!");. Probably using a threadpool behind the scenes, though :)
I remember libuv specifically treating stdio and not turning file descrptors nonblocking and farming reads/writes out to a thread pool.
I haven't double checked with the libuv source code, but the current Node.JS docs mention:
Writes may be synchronous depending on what the stream is connected to and whether the system is Windows or POSIX:
- Files: synchronous on Windows and POSIX
- TTYs (Terminals): asynchronous on Windows, synchronous on POSIX
- Pipes (and sockets): synchronous on Windows, asynchronous on POSIX
Which seems to imply that writes may truly be asynchronous nowadays (depending on conditions).
Thanks that's all definitely quite valuable!
it can be called synchronously and asynchronously, and implementations are going to need to deal with that anyway, right?
You're right, but there's also, in my opinion, a lot less ceremony around "call the function with a list" vs working with streams. I'm still working through bindings generation in Rust for example but bindings for stream<u8> are going to be quite different than bindings for WASIp2 streams today for a variety of reasons. Personally given something as "conceptually simple" as stdio I'd prefer to do something simple by default and have the power-user use case of async stdio be more hidden away.
The color-blind-ness means that if both the producer and consumer are "power users" then everything works out, as well as if one isn't a power user it still works just not as efficiently as otherwise.
With that in place, I'd say:
- Stdin/out: asynchronous binary stream
- Logging: synchronous & string-based
And in some environments, the logs are redirected to stdout/err.
In my mind this sort of support is mostly related to guest languages where the logging builtins are always available but are conditionally not used in CLIs when the stream-based main signature is used instead. That would mean that the Rust-level std::io::Stdin for example would be the multiplexing point for either stream<u8> or call-the-builtin.
For today the "stdio is just free functions" idea is more-or-less the future stand-in for "logging builtins" where we'd one day remove the WASIp3 stdio interfaces and replace them with logging builtins. In that sense just-a-free-function better matches the possible future state of builtins.
Aside from Node.JS, .NET's Console.In/Out/Error properties are TextWriters/Readers wrapping a stream and expose *Async versions of their operations
Many I'm really showing my lack of expertise here... Also man that matrix of possibilities in libuv is quite something, I had no idea!
I'm in favor of simplifying the interface by moving away from streams.
For reference, this is what it looks like to write to STDERR in Rust using wasi:[email protected] APIs directly today: https://github.com/rvolosatovs/sqlx/blob/e545ffb655472a26b057e67c177396403e009255/examples/postgres/todos/src/lib.rs#L108-L110
write: func(data: list<u8>) -> result would certainly be much nicer to use.
That said, however, I think we still should mark all stdio as async, for the following reasons:
- Implementing stdio as
asyncin the host is fairly trivial even if using a thread pool. While it's likely not really asynchronous (although it could be, as @badeend pointed out), it is from the perspective of the guest - the guest can still do more work in-between the call towriteand awaiting of the result.
fut := stdout.write(b"hello")
call_some_func() # guest should be able to still call this function, since the host can run this on a separate thread
fut.await
- Guests that don't care about async stdio can still generate the bindings as "sync"
- Guests that do care about async stdio can directly use it. For example, in Rust,
tokio::io::Stdoutandtokio::io::Stderrcould be truly asynchronous onwasm32-wasip3target by using these APIs directly. If these APIs were not marked asasyncin WIT, then third-party libraries (liketokio) would not be able to depend of them beingasyncby default
Personally, in Wasmtime at least, I want to make stdio blocking-by-default and not async. In my experience stdio is a critical debugging utility and adding infrastructure/complications to that risks masking bugs or making things more difficult. For example if Rust were to implement async stdio but C were to implement sync stdio, it might be possible to see prints in reverse order depending exactly how the code looks. This is added on top of the fact that printf and println! are both synchronous, not asynchronous.
To clarify though guests/hosts can, as you point out, still do whatever they want. I expect tokio to import async versions of stdio, and guests using sync can use sync regardless of how the API is tagged. In that sense this truly is a question of purely defaults, and that's where I would personally prefer to default on the side of simplicity -- the API doesn't have async and the host implementation probably doesn't even start off as async but instead it's just a blocking write.
In terms of simplicity and hello world, what we're working with is:
(module
;; sync import of `write: func(bytes: list<u8>) -> result;`
(import "cm32p2|wasi:cli/[email protected]" "write" (func (param i32 i32) (result i32))) ;; standard mangling
(import "wasi:cli/[email protected]" "write" (func (param i32 i32) (result i32))) ;; non-standard mangling
;; sync import of `write: async func(bytes: list<u8>) -> result;`
(import "cm32p2|wasi:cli/[email protected]" "[async]write" (func (param i32 i32) (result i32))) ;; standard (?) mangling
(import "wasi:cli/[email protected]" "[async]write" (func (param i32 i32) (result i32))) ;; non-standard mangling
)
I realize it's a little ridiculous to optimize for hello world, but in some sense I also see this as important. It's surprising that you import an [async] name and get sync behavior. This detail is normally hidden from folks so they don't have to worry about it, but hello-world writers will run into this.
For example if Rust were to implement async stdio but C were to implement sync stdio, it might be possible to see prints in reverse order depending exactly how the code looks. This is added on top of the fact that
printfandprintln!are both synchronous, not asynchronous.
I'd expect for Rust standard library, most standard libraries, in fact, to assume sync stdio. async stdio would be for "power-users". async being a hint for Wasm runtimes, rather than guests.
I expect tokio to import async versions of stdio
In case that stdio is blocking by default (i.e. async I/O is non-standard), it seems more likely that Tokio would not support stdio on wasm32-wasip3, as it does not now, instead, likely, wait for thread support to land and use a thread pool.
I'm trying to point out that async stdio default in runtimes would provide the most compatibility, whereas sync stdio default in runtimes would limit it and prevent guest libraries from depending on it, thus limiting the potential set of "lift and shift" scenarios "componentizing" existing applications. It appears that the right way to signal the async-by-default stdio is adding the async keyword in function definitions.
Alternatively, since both hosts and guests are free to do whatever they want anyway, how about we document the fact that stdio is expected to be implemented async, and so guest code can depend on the fact that stdio is async, if it wants to, without adding async keyword in the WIT, so signature stays write: func(bytes: list<u8>) -> result;?
I agree that async stdio in Wasmtime would be the most flexible, but it's also the most complicated to implement. My hunch is that 90%+ of users of stdio use sync anyway, so all the work done to make it async would be generally lost anyway. Today it's all sync anyway, so this would be fresh work for wasmtime-wasi. The async nature means that if two components are composed together and they happen to both use async it'll all work out, but for the host Wasmtime will be synchronous. The one possible thing I can think of is that Wasmtime multiplexes "the native host stdio" and "embedder-supplied stdio" where the latter could be async-by-default and the former is sync-by-default.
Effectively what I want to reach for is the opposite of what you're thinking, I want to document these functions as sync and recommend everyone uses the sync versions. The async versions are always there for scenarios like Tokio, and the fact that the Rust guest thinks it's doing async stdio with Tokio when it's actually blocking is something we can either configure in Wasmtime at some point or do something else about.
Just to return to this issue since it's probably time to get a plan sorted out for 0.3.0, I think what @alexcrichton proposed in the root comment is the way to go (modulo maybe no need for async on read).
Separately, I'd like to update the "1.0 goal state" suggested in my last comment based on some useful offline discussion with @alexcrichton @sunfishcode and @posborne.
So, if we assume the following two additions to the Component Model before 1.0:
-
optionalimports - an always-available built-in
logfunction that sends outputs to whatever attached developer-facing console or observability service (or /dev/null, depending on log levels, etc)
then I think a world with roughly the following shape makes sense for 1.0:
world command {
import optional stdout; // with 'stdout' as added for 0.3.0
...
export run; // with type 'func() -> result', as in 0.2.0
}
where the expectation (for wasi-libc and other language runtimes) is: send your stdout to wasi:cli/stdout if it has been provided, otherwise fall back to sending stdout to the built-in log function. If a world (like wasi:http/proxy) does not import stdout or stderr at all (even optionally), the "if not provided" branch in wasi-libc would always be taken, sending all stdout to the log function.
With this approach, a host or parent component always has the optionality of getting the raw stdout bytes of a wasi:cli/command component as the non-logging output of the component (which can be virtualized or processed by other components, etc). This optionality essentially reflects the ambiguous nature of stdout ("is it the output of the computation or is it for the developer?").
The use of optional imports is the reason to keep stdout as an import instead of returning stdout from main as a return value (as I suggested above). From this, it follows that stdin and stderr (and argv) would stay imports as well (since the "call main() as an imported function" use case isn't served if only some things are function params/results). Also, for the reasons @alexcrichton listed above, for simple single-shot-single-component cases, imports are simpler. While this breaks the little JS code snippet I wrote in my last comment, once we have runtime instantiation in the Component Model, any given wasi:cli/command component could be mechanically adapted (by the producer or consumer) to export a function taking and returning a stream, which seems Good Enough.
Anyhow, this probably isn't the last iteration of what precisely wasi:cli/[email protected] should look like, but I thought I'd share updated thinking for further consideration (although perhaps serious discussion should go in a new issue; sorry for scope-creeping this one).
No language I'm aware of exposes this as an async interface or as a stream.
https://en.cppreference.com/w/cpp/io.html
Just as a follow-up: I have been hearing various use cases where having proper stream<u8>s for stdin/stdout/stderr would be beneficial to guest code, so I'm less sure that wasi:cli/std{in,out,error} should use list<u8> vs. stream<u8> than in my last comment. The important thing (for me at least) remains the idea that these interfaces belong as imports (later optional imports with fallback to a logging built-in), rather than export params/results, though.
One of my major motivations here is debugging components with prints. I feel it's extremely important when debugging something that after the print call returns that it's guaranteed that whatever syscall was necessary was executed and completed and the print will make its way out shortly. We've had issues in Wasmtime otherwise along the lines of:
- If a print is sent to a separate "task" somewhere else (e.g. a host-level task) then it's not guaranteed that after the print returns in the guest that the print has even happened on the host. This can be quite confusing during debugging because it appears that events are happening out of order since events in the guest may appear to happen before the print actually went out.
- If a print is scheduled after a write but not actually performed it runs the risk of being lost. For example in Wasmtime right now there's no need for "graceful shutdown" of WASI (intentionally so, that's an onerous requirement to put on all embedders). If a print is sent to a separate task and then the guest exits (or worse, traps), causing the store to be torn down then the write could be lost as the event loop wasn't "pumped" another time to get the print out.
Wasmtime's implementation of stdio, which currently uses streams for p3, fails on both of these counts. It spawns a task for all writes which means that after a WASI-originating write completes it's actually just queued up to get sent out somewhere else. This somewhere else is in the same event loop and will eventually print but it suffers the issues above.
Basically I don't want to be in a situation where we rug-pull ourselves where we tell everyone to use p3 and there's also no reliable way to debug it. We talked about hypothetical solutions earlier today for this on the Wasmtime side of things but all these same guarantees will need to make their way into jco and other runtimes as well.
On one hand I can try to make more proposals/alternatives. For example these interfaces could have both list-taking functions as well as stream-taking functions, and guests call whichever is appropriate. On the other hand though I feel like there's a disconnect with this issue that needs to be resolved first. I feel like pushing things towards streams/async for stdio is largely all staked on the assumption of "surely we can work things out," but at the same time no one is actually working anything out. Everyone's assuming someone else is going to deal with it or have solved it by the time they get to it. From my own personal perspective the way I'm evaluating this is experience from:
- I'm one of the few that's gotten things working in guests related so far to p3.
- I was heavily involved in the development of p2
- I was one of the original authors of async I/O and Tokio in Rust
- For a time I maintained the libuv integration in Rust and eventually helped migrate away from it.
All of my own experience points towards "just functions" and no streams. No one is improving bindings for guests at this time to make these functions easy to use. No one is working on a wasip1-to-wasip3 adapter to stress this by getting more usage. No one is current slated to improve how host streams/futures work in Wasmtime. In my mind so much of the assumption of streams/async is built on a tower of abstractions here of which much is missing and much does not have a concrete plan of how it will actually exist.
At the end of the day though I've been writing/rewriting this comment for the better part of an hour after spending another hour this morning talking about this. While I feel pretty strongly about this I have no real reason to believe that the original proposal is going to work out any better than a streams-based alternative. There's no escaping the realities of the surprises one will encounter using p3-based stdio with Wasmtime today, but I could easily be over-rotating on all this too. Effectively, I'm at a loss of how best to proceed.
Ok in an effort to help continue pushing this forward I've been thinking about this and after some discussion with @rvolosatovs I've distilled two things that I believe need fixing if stdio is "just a stream":
- When a guest writes to stdout/stderr it needs the ability to block the current program until the bytes are actually written as that's what the guest language often requires. Component model streams provide a rendeszvous-style primitive but there's no guarantee about what happens after the rendeszvous. For example if bytes were handed off to some other task there's no knowledge of whether that task has fully processed the bytes. An example is that the task may need to perform multiple writes to a TCP stream for example if stdout is a TCP socket (a bit contrived) but from a guest language's perspective
println!isn't done until the writes have all happened. In WASIp2 this was solved via thecheck_writeprotocol plus flushing/etc. Basicallyblocking_write_and_flushwill package all this up, but that's no longer possible if stdout is just a stream. - When a write to stdout fails, there's no means of communicating this error to the guest. This can end up in pathological cases where if you create a stream, "append" it to stdout, and then write to the stream, that always succeeds. The write side doesn't realize closure until its written the first set of bytes, but the bytes are already taken from the guest so the guest isn't otherwise waiting any more. The guest otherwise has no means to discover if an error happened.
There are a variety of ways that @rvolosatovs and I brainstormed to possibly fix these behaviors. They generally fall into one of the categories of (a) change Wasmtime's implementation, (b) change the WASI APIs themselves, (c) change the usage of the WASI API, or (d) change the component model specification itself:
- Communication of an error could be fixed by having the "append this stream" API itself be an async function or return a future. This async function or future would return
option<error>of some kind resolve once encountered. This technically solves the case I outlined above if it's one-write-per-stream, but in a more common setting of one stream reused many times it's no a full solution because there's no way to wait for the result of one write, just for all writes. - Wasmtime could change its implementation to avoid spawning a task when a new stdout stream is appended. It could be refactored to "fuse" the host-side sink-of-bytes to the guest write-half meaning that it's less-so a rendeszvous operation when a guest writes bytes and moreso a "do the async operation right here and now". This heavily priviledges the host, however, as guests would not be able to impelment this functionality in a virtualization scenario.
- WASI could specify that 0-length writes can be used to "block" the caller until all previous writes have completed. This could be hooked up in the host (somehow? unsure?) to only perform the 0-length rendesvous once the host-side reader finished writing the previous chunk that it read. Coupled with (1) above a 0-length read reporting "closed" could then consult the future/subtask to if there was an error.
- Component-model level streams could be enhanced to have some sort of "flush" or "shutdown" or similar operation which is hooked up... somehow. I haven't fully thought this through but the rough idea is that WASIp2 I/O streams would have more chunks of them migrated to the native component model streams "somehow".
- Non-stream functions do not suffer from the above concerns. The functions can avoid returning until the write has been fully processed and it's easy to return a value indicating whether an error happened or not.
One key thing I've been slowly realizing is that I don't think we can "just" fix this on the host in Wasmtime. If we want virtualized scenarios to work as robustly as non-virtualized scenarios then something must be done within WASI APIs or the Component Model itself. Thus my previous thinking and comments about "fusing" I/O objects to streams without tasks in Wasmtime, while still helpful, will not solve these issues.
What if we do option 2: have Wasmtime do the stdio I/O "right here and now"?
Concerning the privileging of the host: Today, stream.read effectively does two distinct things: it copies data into the buffer, and then it tells the paired stream.write call to complete. So what if we planned to introduce builtins that each do just one of those things, named say stream.read-data and stream.read-complete? Guests could then do stream.read-data, do the I/O "right here and now", and then do stream.read-complete telling the stream.write to complete, effectively just like the host.
stream.read-complete is also the kind of thing that it's easy to see us wanting for other reasons too, as we add more ways to do I/O than just eager copying between linear memories.
One caveat is that something like libc read would probably always do the read-complete step automatically, because libc doesn't have a function that gets called at the time that the ideal time for stream.read-complete. Users using libc or similar abstractions wouldn't be able to get the "right here and now" behavior unless they wrote custom code. But this might be an acceptable limitation, for two reasons:
- Existing Unix pipeline-style code already doesn't expect to be able to do this. For example, in a Unix command like
a | b > c,a's writes tobcomplete beforeb's writes tocstart. A Unix OS can do the I/O to the filecin the style of "right here and now", butbitself can't do the I/O to the OS that way. - Other than stdio and similar things, many use cases won't care about this "right here and now" behavior. It reduces overall concurrency by forcing someone to wait longer before moving on to do other work.
How does that sound?
While I agree that could work, that's a major refactoring in Wasmtime plus a relatively major new feature in the component model from becoming a reality. I personally would consider some sort of solution for this problem table stakes for an official 0.3 release. Furthermore it would still require changes necessary to communicate an error as a result of I/O like (1) above.
Met with folks today and current plan is (correct me if I'm misremembering anything):
- Soon - Joel/Roman/I (or some subset therein) will explore the feasibility of enabling Wasmtime embedders to arbitrarily block completion of writes until the host finishes its I/O.
- Soon - the current functions will rename "set" to "append"
- Soon - A decision will be made about whether these function signatures will return a future-of-an-error-type where the error-type is stdio-specific. Regardless a libc-style adapter will always report an error one way or another on a short write when the stream is closed
-
Future - plan out
stream.read-{data,complete}as @sunfishcode outlined. Until then virtualized scenarios will not be able to guarantee to have I/O done by the time the write completes.
Investigation will need to complete in the pretty near future if the first point here is possible. The viability of this plan currently hinges on that.
Soon - the current functions will rename "set" to "append" Soon - A decision will be made about whether these function signatures will return a future-of-an-error-type where the error-type is stdio-specific. Regardless a libc-style adapter will always report an error one way or another on a short write when the stream is closed
These WIT changes are implemented in https://github.com/WebAssembly/wasi-cli/pull/82
The conclusion of this issue so far is that:
- WASI will continue exposing stdio as only WIT
streams. - wasmtime will wield its special host privileges to block the guest and never actually return a blocked status from those "async" streams
However, guests can not assume wasmtime specific behavior and are forced to lower the stream.write synchronously in order to implement e.g. printf. This was "fine", until https://github.com/WebAssembly/component-model/pull/578 came along. After that spec change, the guest will trap in non-async contexts. Given that stdout is basically WASI's equivalent of console.log, either:
- the guest needs to be updated to always call the
asyncversion of stream.write and be prepared to build up a buffer themselves in case the write can not succeed immediately. Or: - WASI needs to be updated with synchronous overloads of the write methods.
- WASI needs to have a dedicated
console.logalternative. This is operationally the same as the previous option, except that the sync method lives somewhere else; outside of the stdio interface.
Much of the discussion above has been focused on going with either a sync function or async streams. But after re-reading the issue so far it's not entirely clear to me why we can't have both? E.g.
interface stdout {
write: func(data: list<u8>) -> result<_, error-code>;
write-via-stream: async func(data: stream<u8>) -> result<_, error-code>;
}
Where the sync function can be used for debugging, printf implementations, hello world demos, etc.
And the stream based API can be used for bulk data transfers, piping into e.g. files, and also for demo purposes but this time showing off the composability aspect of WASI.
One part I can clarify is that Wasmtime isn't using host powers for stdio where stdio streams are a normal StreamReader<u8> in the host and do stream-y things. The part where Wasmtime is "special" is that it doesn't actually do anything async in the stdio streams. For example with WASIp3 Wasmtime bottoms out in AsyncWrite for StdioOutputStream which has no async in it at all. This is in contrast to stdin, however, which does async-y things.
My understanding in light of https://github.com/bytecodealliance/wasmtime/blob/ab030780b67ce79c8567cd245353a0e7f1e05d27/crates/wasi/src/cli/stdout.rs#L97-L117 has been that guest standard libraries will be forced to do an async write on std{out,err} followed by blocking waiting for a result. That will conventionally work when the receiver of the bytes does what Wasmtime does, accepting the bytes immediately without ever blocking. This limits virtualization because with a guest you can't guarantee that the writer is blocking per se, but it's at least possible with the host.
By conventionally doing this it's expected to work for most use cases (prints never block). It also works in use cases where the export from the component is async (the guest actually blocks eventually). It also works implementation-wise where writes of all kinds are funneled through stdout and libc can't, for example, differentiate from printf-vs-fwrite-this-thing-to-stdout.
Overall it's not a great state of affairs per se (virtualization is difficult/finnicky) but the longer-term solution is hoped to be:
- There will be builtins to the component model itself for a
console.log-like-thing - There will be optional imports in the component model
- Libc/guests will optionally import stdout interfaces, and then conditionally use them, if present, depending on if the entrypoint is sync/async.
If the stdio interfaces are not provided or if the entrypoint is a sync export then the guest will use component model intrinsics, guaranteeing that things always work.
Now all that being said I'm not personally opposed to sync + async functions in the interface. We haven't done that historically because the rationale was lifting/lowering options meant that it didn't really matter and we didn't need more than one. The calculus has changed now with async-in-the-type so I agree it'd be reasonable to revisit and consider providing write. One part that I don't know how to do, though, is what to put in libc to have it decide which is the better to use. (maybe the sync/async entrypoint idea? unsure...)