Optional Imports Phase 1: Tooling Conventions
Here I'd like to write up the result of some discussion that was had today about optional imports. In the fullness of time optional imports is expected to be a relatively complex feature that's integrated into type-checking, WIT, the binary format, etc. In the near future however it should be possible to get much of the benefit with far less work.
Motivation
The loose objective at this time is that it would be nice to enable wasi-libc to use the latest-and-greatest WASI APIs immediately as soon as they're ready. Today there is no great way to answer the question of "when have sufficient runtimes upgraded to a WASI version that wasi-libc can use it" and the hope is that we can sidestep the question entirely.
A concrete use case today is the exit-with-code function in wasi:cli/exit. This is currently unstable but may likely become stable soon, at which point it's up to guests to be able to use it.
Design
The main idea in the near future is that we can avoid a bunch of spec/runtime work by exclusively implementing optional imports in the toolchain, notably bindings generators and wasm-component-ld. The general flow of things would look something along the lines of:
- Bindings generators would support per-function configuration of "this should be an optional import". For example Rust
wit-bindgen'sgenerate!macro has anasyncoption and that would be mirrored as a newoptionaloption. In this manner select functions can be generated as "optional" instead of required.-
Example:
wasi-libc's binding generation for WASIp2 would pass--optional wasi:cli/exit/exit-with-codeto thewit-bindgen cCLI invocation.
-
Example:
- Language bindings would model optional imports perhaps similarly to weak functions. There'd be a generated function which would return a nullable function pointer. This pointer would be checked at runtime to determine whether the import was present or not (null-vs-not) and applications would dispatch appropriately.
-
Example: in C
exit-with-codewould be bound as something along the lines of
typedef void(*wasi_cli_exit_exit_with_code_func_t)(int); // type signature of `wasi:cli/exit/exit-with-code` exit_with_code_func_t wasi_cli_exit_get_exit_with_code(void); // get the function pointer -
Example: in C
- Bindings generators would continue to import the exact same signature they import today, name mangling and all. Bindings generators would also import the same named-function, prefixed with
[is-available]. This new function would have the signature(func (result i32))- Example: the raw wasm imports would look like
(import "wasi:cli/[email protected]" "exit-with-code" (func (param i32))) (import "wasi:cli/[email protected]" "[is-available]exit-with-code" (func (result i32))) ;; ... or with BuildTargets.md ... (import "cm32p2|wasi:cli/[email protected]" "exit-with-code" (func (param i32))) (import "cm32p2|wasi:cli/[email protected]" "[is-available]exit-with-code" (func (result i32))) - Bindings generators would supplement the current custom section that holds documentation/stability information with these optional annotations, indicating whether a function should be considered optional or not.
-
Example: Somewhere around here in
PackageMetadataa new map would be added for optional imports.
-
Example: Somewhere around here in
-
wasm-component-ldwill gain new--target-world-wit ./path --target-world foo:bar/worldflags. These flags indicate what the world the final artifact should target, regardless of what the inputs are:-
Example:
wasm-component-ld --target-world-wit ./my-wit --target-world wasi:cl/[email protected] ...
-
Example:
- When creating a component, the
wit-componentcrate would then test to see if optional imports are present in the target world. If an optional import is present in the target world (and the target world is specified), then the final component imports the function. This function is lowered as usual and the[is-available]function is a synthesized function that returnsi32.const 1. If the optional import is not present in the target world, or if the target world is not specified, then the[is-available]function is synthesized to returni32.const 0and the actual function import is satisfied with a function that only containsunreachable.
With all of this put together this will have achieved the goal that it's possible for wasi-libc to update immediately to use new WASI APIs without affecting consumers by default. The componentization process will require a flag to opt-in to using these new APIs by passing a new --target-world* flag to the linker via toolchain specific methods. If this isn't done then no optional import will be used, and if specified optional imports will only be turned on if they're present in the target world.
Downsides
The major downside to this approach is that there's no actual dynamic detection at component runtime, instead the "detection" is still done at compile time. This means that even if a runtime supports the API and the guest has optional support for using it the API may not be used. Fully fixing this though is thought to be "phase 2" with more full integration into the component model itself which is expected to take significantly more time than the implementation outlined above.
I've done my best to capture the result of our discussion today, but if anything looks awry to folks please let me know! I realize that this issue will not actually result in any changes in this repository itself since it's not proposing that the component model is changed just yet. Despite this though I wanted to put this in a relatively central location to get discussion since it affects lots of pieces of tooling and stakeholders.
Who is going to benefit from specifying --target-world? As the end consumer, I am only going to specify the target world that's "out there" already, so it's not too different in that regard from waiting for the libc to adopt it.
Now, I can be more aggressive with the new target world this way, but for such purposes we already have many tools in static linking. For example, libc could provide bespoke objects that'd implement some internal shims, one for each interesting target world variation. Indeed, such shims could be public, essentially open-coding the proposed synthesized methods. This kind of pattern could work for other <world+C binding library for this world> libraries as well.
--target-world
By the way, what is the difference between this and --component-type? These flags look very similar.
The target user of --target-world is a toolchain/sdk/user that wants to explicitly target a newer version of WASI and enable transparently deep dependencies in their component to use the newer WASI world. For example wasi-libc provides an exit symbol for POSIX compat but doesn't use the code right now. While it's possible to cook up wasi-libc-specific solutions it then wouldn't be possible to simultaneously solve cross-language use cases such as Rust's std::process::exit if it didn't use wasi-libc under the hood. Put another way I think you're right that this isn't really truly necessary, but as a stepping stone to a "more full" solution I personally think it makes sense to do.
By the way, what is the difference between this and --component-type?
Definitely worth bikeshedding and/or more docs... The idea is though that --component-type describes the types that a component is using if it's not already embedded in the binary itself. Some toolchains aren't able to use tricks like Rust and C which embed type information in objects to make its way to the final link. Thus --component-type is auxiliary information of "by the way in the rest of the build these component types were used (worlds, interfaces, etc)".
The --target-world flag says "ok the final binary should be prepared to target this exact world". That would then inform orthogonal decisions in the linker such as:
- Optional imports that are available in the target world are all supplied.
- Optional imports that are not available are all trapping stubs
- (future) The linker could emit a first-class error message if the component does not conform to the shape of the world specified (e.g. this component imports "X" which is not available in world "Y")
The major downside to this approach is that there's no actual dynamic detection at component runtime, instead the "detection" is still done at compile time.
A runtime could generically deal with optional imports for interfaces it knows nothing about, right? Combined with simple "import what you don't know" composition it seems like that would get you dynamic detection in practice? It might be worth a wasm-component-ld --retain-all-optional-imports or similar?
Could you explain more what you're thinking @lann? For example Wasmtime has no support for optional imports today, so without component model changes I'm not sure how it could achieve what you're thinking
The optional imports wouldn't have to be resolved in wasm-component-ld; they could be retained and resolved later, either by an intermediate processing step (e.g. wasm-tools component deoptionalize) or by an optional-imports-aware runtime.
I guess a downside here is that you'd either have to adopt this convention as mandatory for WASI 0.3 or accept that these components with unresolved optional imports wouldn't be portable without extra processing.
Edit: I suppose I was wrong that non-optional-import-aware composition would be possible, since [is-available] would make the import name invalid. That makes this idea less appealing.
Ah ok I see what you mean. And yeah it would require having some sort of convention about testing "is this interface/func available" which would start coming up on runtime support and such and I feel touches on sort of the next phase of optional imports with true support in the component model
I was pointed here from https://github.com/WebAssembly/component-model/issues/576 and besides wanting to express interest, wanted to share a quick thought (I may also just misunderstand some details, so please step on my toes)...
Today I can easily add a new version-gated function to an imported interface in a minor release. That's not true for functions in exported interfaces. However, I would love to be able to do so, e.g. accept any component build against v0.1.0 and introspect on the host-runtime level if exported functions (or interfaces) added in v0.1. on the host-side1 are available.
If I understood @lukewagner correctly, the longer-term consideration is to make optionallity explicit, something akin to:
interface i {
bar: optional func() -> string; /// does not have to be exported
}
That's not necessarily what I want. I would like to explicitly add non-optional functions but treat them as optional from the host-side, e.g.:
@since(version = 0.1.0)
interface i {
@since(version = 0.1.0)
foo: funct() -> string;
@since(version = 0.1.1)
bar: func() -> string;
}
In other words, I think I would prefer the "shortcut" at the bindgen-level to have my host accept components build against both, v0.1.0 and v0.1.1. I am happy to handle the distinction in the host implementation. Hope my gibberish and proposed use-case make at least some sense? - Thanks