Prompt and Context
A plugin host runs components written in several languages. Existing components depend on WASI 0.2 wasi:io resources, while new components want WASI 0.3 async func, stream, and future. Design an interface for log streaming, remote calls, and cancellation. Explain how the host schedules work, limits resources, and keeps older components working.
This tests asynchronous semantics at an ABI boundary, not whether every function is marked async. WASI.dev describes 0.3 as adding native async and refactoring interfaces; the Component Model FAQ says wasi:io is removed and readiness is carried across boundaries by Component Model primitives. A strong answer separates the interface contract, runtime scheduling, and business side effects.
What the Interviewer Evaluates
First classify the operation as a single result, a continuous stream, or a cancellable long-running call. Then choose a future, stream, or ordinary return. Explain why 0.2 pollable resources created a “sandwich problem” at component boundaries and how 0.3 lets the runtime own waiting and wake-up propagation.
The answer also needs backpressure, resource ceilings, error classes, and migration. “Async is faster” says little; the interviewer wants consumer pace, future completion, post-cancel side effects, and the adapter path for old components.
Questions to Clarify Before Answering
Single value or continuous stream
Ask whether the remote call returns one response or emits logs, file chunks, or events. Use a future for one result and a stream for an incremental sequence with an explicit end signal and capacity policy. If consumers can pause, define the buffer limit and producer behavior.
Must cancellation prevent side effects?
Locate cancellation in the lifecycle: queued, executing, or after an external write. A component boundary can carry a cancellation request, but it cannot roll back a committed external side effect. That requires an idempotency key, status lookup, or compensation.
Can both versions run during migration?
Confirm whether the host can load 0.2 and 0.3 together, whether old components must remain unchanged, and whether the runtime supports an adapter. Those answers determine side-by-side runtimes, a 0.2-to-0.3 adapter, or a coordinated cutover.
30-Second Answer Framework
“I choose the primitive from the semantics: a one-shot remote result uses a future, continuous logs use a bounded stream, and synchronous pure computation stays synchronous. WASI 0.3 puts readiness and wake-up in the Component Model’s native ABI instead of making each component own a pollable. The host still sets concurrency, buffer, deadline, and cancellation limits; cancellation stops further work but uses idempotent status for external effects. I keep the 0.2 world during migration and switch through an adapter and a compatibility matrix.”
Step-by-Step Deep Dive
Step 1: Put async semantics in the WIT world
Define the smallest interface; do not make every function asynchronous. This WIT-style pseudocode separates a stream from a single result:
package acme:plugin@0.3.0;
interface logs {
record chunk { bytes: list<u8>, end: bool }
export next: func() -> future<chunk>
}
world worker {
import logs;
export run: async func(input: string) -> result<string, failure>;
}A future denotes one eventual value, not an unbounded background task. A stream is for a sequence delivered over time. The interface must also define errors, end-of-stream, and post-cancel state; otherwise generated bindings in different languages can disagree.
Step 2: Explain the 0.2-to-0.3 boundary
WASI 0.2 expressed I/O readiness with pollable, input-stream, and output-stream. As component resources, their readiness had to cross separate caller and callee async layers, producing the sandwich problem. WASI 0.3 uses async func, future, and stream primitives so the runtime can propagate readiness across components.
This is not a forced rewrite of an old binary. The official FAQ explains that a 0.3 runtime can map 0.2 imports to 0.3 primitives at the host boundary. Keep the old world, let the adapter perform the translation, and upgrade components one at a time.
Step 3: Give streams a backpressure and memory boundary
The producer cannot write forever. Set element, byte, and wait-time limits per stream. When the consumer falls behind, pause reads or return an explicit overload result. Isolate intermediate buffers by tenant and component so one slow consumer cannot exhaust runtime memory. For files and logs, pass chunks or controlled handles instead of copying the complete object across the boundary.
Step 4: Design future cancellation and errors
Before a future completes, store an operation ID, deadline, and cancellation signal. Cancellation should stop work that has not started and send a cooperative stop to running code. If an external service may already have written, mark the state UNKNOWN; query with an idempotency key or compensate instead of blindly retrying. Distinguish timeout, cancellation, business rejection, dependency failure, and protocol incompatibility so each gets an appropriate retry policy.
Step 5: Keep scheduling in the runtime
Components should not each start a hidden event loop for the same I/O class. The host runtime owns wake-ups, concurrency, and fairness; components declare interfaces and return results. Keep short synchronous hot paths synchronous to avoid unnecessary task creation. The Bytecode Alliance roadmap notes that async infrastructure on synchronous calls can add overhead, so benchmark synchronous adapters, async boundaries, and business work separately.
Step 6: Build a staged migration and verification matrix
Record the WIT package, WASI version, runtime, adapter, and component digest in the release manifest. Test a 0.2 component on a 0.3 host, a 0.3 component on an older host, mid-stream cancellation, unknown side effects after timeout, slow consumers, and reconnects. Shadow the new path and compare completion rate, end-to-end latency, peak buffering, cancellation latency, and retries before version-based rollout.
High-Quality Sample Answer
I would not mark every interface async. I would classify each operation: a one-shot remote result gets a future, continuous logs get a stream, and pure local computation stays synchronous. The key WASI 0.3 change is putting async func, future, and stream into the Component Model’s native ABI, replacing the 0.2 wasi:io pollable resources so readiness and wake-up can cross component boundaries.
At runtime I would cap stream elements, bytes, and wait time. A slow consumer pauses production or receives overload; it cannot consume shared memory without limit. A future carries an operation ID and deadline. Cancellation stops work that has not started, while an external write becomes UNKNOWN and is resolved with an idempotency-key lookup or compensation rather than a blind retry.
For migration I keep the 0.2 world and map it at the host boundary with an adapter, then shadow and roll out by component version. The matrix covers old and new components, cancellation, backpressure, errors, and reconnects. If the runtime or toolchain lacks 0.3 support, I delay the world cutover instead of pretending the ABI is compatible.
Common Mistakes
- Mistake → Treating a
futureas any background task → Why it fails → A future is one completable result; lifecycle and cancellation still need a contract → Fix → Track an operation ID, deadline, and cancellation state. - Mistake → Using an unbounded queue for stream peaks → Why it fails → A slow consumer turns memory pressure into a runtime outage → Fix → Bound bytes and elements, then propagate backpressure or reject clearly.
- Mistake → Claiming 0.3 automatically rolls back an external write → Why it fails → ABI cancellation is not a distributed transaction → Fix → Use idempotency, status lookup, and compensation.
- Mistake → Deleting the 0.2 world immediately → Why it fails → Older components and toolchains may still depend on pollable resources → Fix → Migrate through an adapter and compatibility matrix.
- Mistake → Giving every component its own event loop → Why it fails → Scheduling, cancellation, and fairness become inconsistent → Fix → Let the runtime own wake-ups and concurrency budgets.
Follow-up Questions and Responses
Follow-up 1: The producer outruns the consumer. Drop data or block?
Classify the data first. Logs may drop lower-severity records while counting drops; financial events must not disappear silently, so persist them to a bounded external queue and return backpressure. In either case, expose tenant, component, and stream IDs in metrics instead of looking only at aggregate throughput.
Follow-up 2: How do you know whether a timed-out future completed?
A timeout means the caller stopped waiting, not that the operation was rolled back. Keep the operation ID and query the dependency. Without a status API, mark the result UNKNOWN and hand it to compensation or an operator. Retry only when the contract guarantees idempotency and the status check confirms it did not complete.
Follow-up 3: A 0.2 component uses pollable. How does a 0.3 host expose equivalent behavior?
The adapter maps the 0.2 resource event to a 0.3 future or stream while preserving close, error, and cancellation semantics. First compare readiness ordering and EOF in a single-component test, then test composition. If the adapter only emulates part of the behavior, the deployment gate must reject worlds that require the missing interface.
Follow-up 4: How do you prove the async rewrite paid off?
Split measurements into adapter time, runtime wake-ups, peak buffering, CPU, memory, end-to-end latency, and business completion rate, then compare with a synchronous baseline. For very short calls with little concurrent waiting, task management and ABI adaptation can erase the benefit; keep a synchronous interface or batch calls instead.