-
Notifications
You must be signed in to change notification settings - Fork 30
[v0.3] model bodies after stream<u8, trailers, error-code>
, trap less
#162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[v0.3] model bodies after stream<u8, trailers, error-code>
, trap less
#162
Conversation
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for writing this up and thinking about lifetimes. Overall, this makes sense as part of the general goal (that we discussed in the earlier BA C-M impl meeting) of, in cases where a child would otherwise outlive its parent, having lazier tombstone/failure semantics.
wit-0.3.0-draft/types.wit
Outdated
/// The headers returned by this function are considered to be a clone of the headers | ||
/// of the request. Changes to the `headers` returned by this function will not be reflected | ||
/// in the immutable `headers` resources, that could have been acquired by calls to `headers` | ||
/// prior to this function being called. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know this is what we discussed, and it is the friendliest option but I remembered later that, when we discussed this as a group in the BA C-M impl call, the idea was to instead say: if you call into-parts
when there is already an existing handle to headers
, that existing headers
handle is put into a state where all header operations fail (how precisely, to be defined per-method). This is symmetric to what you're describing with body
and it matches more-closely how a built-in WIT child use<headers>
return type would work (bindgen wouldn't know to clone). WDYT?
(and similarly in response
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like we'd need to wrap fields#get
, fields#has
and fields#entries
results in a result<T, header-error>
, as well as introduce a header-error#moved
or header-error#corrupted
to fully cover this case.
wasi-http/wit-0.3.0-draft/types.wit
Lines 174 to 182 in 9caa8b3
/// Get all of the values corresponding to a name. If the name is not present /// in this `fields`, an empty list is returned. However, if the name is /// present but empty, this is represented by a list with one or more /// empty field-values present. get: func(name: field-name) -> list<field-value>; /// Returns `true` when the name is present in this `fields`. If the name is /// syntactically invalid, `false` is returned. has: func(name: field-name) -> bool; wasi-http/wit-0.3.0-draft/types.wit
Lines 210 to 219 in 9caa8b3
/// Retrieve the full set of names and values in the Fields. Like the /// constructor, the list represents each name-value pair. /// /// The outer list represents each name-value pair in the Fields. Names /// which have multiple values are represented by multiple entries in this /// list with the same name. /// /// The names and values are always returned in the original casing and in /// the order in which they will be serialized for transport. entries: func() -> list<tuple<field-name,field-value>>;
Otherwise, I'm afraid this behavior would inevitably cause bugs in applications using this API.
I feel like the behavior proposed in this PR is less surprising, provide better developer experience and introduce no overhead. That said I'm happy to add a new header-error
and wrap the methods with a result, if you think we should do that. Otherwise, perhaps we should discuss this in next BA C-M impl call?
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
I fear that this will result in weird spooky-action-at-a-distance situations, and very hard-to-debug issues in code that doesn't expect its handles to be closed behind its back. Would an alternative be for all of these calls to return results, with an error being returned if the child resource has already been acquired and not closed? That would be similar to what web streams do, for example: if, say, you call Note that the only change I'm proposing is to not auto-close existing resources when calling, e.g., |
IIRC, the reason we went with auto-close was the That actually seems to align with the idea of Note that I'm opting for @lukewagner WDYT? |
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
This seems much better to me, thank you! In particular |
I suppose that's another valid option to consider and I do like the "locality" of the approach. But I worry that, because GC languages usually lack prompt finalization, an "unreachable but not yet finalized" Taking a step back: if a single component instance calls The only remaining problem is that |
If two let Some(b1) = req.body();
let Some(b2) = req.body();
let Ok(fut) = b1.finish().await;
let Ok(None) = fut.await;
let x = b2.finish().await; // is this an error?
// If x is an error, how does it differ from
// the case where a reference to the stream exists? Another thing to note is that we'd need to make |
I think I agree that we can change things to just return handles representing the same underlying resource, yes. Even better would be if the same handle were returned, but I remember that there are issues with implementing that efficiently. Regarding |
It'd do the same thing as if you called
Sigh; agreed. But yeah, some very thorny (perf and leak) issues if we try to provide handle identity.
Good point! That would address Roman's question regarding |
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
f4f168e
to
eac8416
Compare
I think that should address all the feedback, @tschneidereit @lukewagner PTAL |
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
1be0fcd
to
07e6cbb
Compare
stream<u8, trailers, error-code>
>
stream<u8, trailers, error-code>
>stream<u8, trailers, error-code>
, trap less
stream<u8, trailers, error-code>
, trap lessstream<u8, trailers, error-code>
, trap less
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
07e6cbb
to
865daa5
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall I like this much better! I left a couple of comments inline that seem relevant, though.
wit-0.3.0-draft/types.wit
Outdated
headers: headers, | ||
body: option<body>, | ||
contents: stream<u8>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This still needs to be optional, since not all requests have bodies, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Previously we discussed with @lukewagner and @dicej that body
resource would be non-optional (#165), that's something that would have addressed #164.
In that scenario requests not carrying a body (like GET) would be represented by empty streams.
Now that there's no body
resource anymore and it's the request/response constructors that return the transmit future, we can reconsider this, indeed, especially since the host would not be able to do anything with the trailers future in that case anyway.
body
parameter will have to become an option<tuple<..>>
though
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for the explanation; makes sense to me
wit-0.3.0-draft/types.wit
Outdated
/// - a stream returned by a previous call to this method has reported itself as closed | ||
/// Thus there will always be at most one readable stream open for a given body. | ||
/// Each subsequent stream picks up where the last stream left off, up until it is finished. | ||
body: func() -> option<tuple<stream<u8>, future<result<option<trailers>, error-code>>>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit unsure whether using option
for this makes sense: it seems overloaded for request that just don't have a body, and lead to confusing silent failures.
Given that, ISTM the better, if slightly more onerous, signature would be
body: func() -> option<result<tuple<stream<u8>, future<result<option<trailers>, error-code>>>, body-in-use-error>>;
(Where body-in-use-error
would be a new thing, where I don't have strong feelings about what it should look like, exactly.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we're making bodies optional again, that seems to follow, yes.
I'd prefer not adding the body-in-use-error
just yet though and keep it at result<tuple<stream<u8>, future<result<option<trailers>, error-code>>>>
for now, verify the design with an implementation and potentially revisit later (as that seems to be a minor change)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could be missing it, but I couldn't find any prohibition against trailers in GET
requests, so if there were an option
wrapping the stream<u8>
, I think it'd need to be inside the tuple
. However, I haven't been able to find a semantic distinction between none
and "zero-length contents" inside RFC 9110, so I worry that by introducing this distinction, we might be creating something that content may incorrectly depend on. (E.g., let's say a component decides to fail if the option<stream<u8>>
is non-none
, but now it's rejecting some
zero-length stream which maybe it gets passed (in a multi-component composition).).
Considering the perf angle (which could be another reason for the option
):
- As an optimization and convenience, I do think it'd make sense to have the
contents
parameter to{request,response}.new
beoption<stream<u8>>
as Till suggested, butnone
could be spec-defined to be normalized to an empty stream internally. - I expect guest code simply won't ask for the content stream for
GET
requests, so source-language stream objects won't be wastefully created. The CABI overhead of creating closed-on-construction streams should also be light, I think.
So perhaps:
body: func() -> result<tuple<stream<u8>, future<result<option<trailers>, error-code>>>>;
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch: I hadn't considered that my proposal would prevent access to trailers as well.
With that, I agree with the proposed interface, with one slight exception: I do think this is a categorically different error from the ones represented by error-code
, and that we should make it its own type. Is this perhaps even a pattern that'd make sense to lift up higher and use the same error type for in other places as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As currently proposed, the error type in this case is actually unit
, e.g. in Rust it's ()
.
Here's the host implementation with the full Rust type: https://github.com/bytecodealliance/wasip3-prototyping/blob/ecd781215e650dc5eb4e6ec4b2fd60858c084654/crates/wasi-http/src/p3/host/types.rs#L669-L700
Does this address the concern?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kinda? It's better than using error-code
(sorry for missing that it's not), but it does seem very low-effort to define a body-in-use-error
type and return that instead. And would make this much more self-documenting.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't seen us use that idiom before in WASI of having singleton error variants; there's usually just a result
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh, you're absolutely right: I had confused myself into thinking that if we move away from error-code
, we need a specific replacement for it. But since the result
here is only going to be err
if the body is already taken out, I agree that that's not needed. Sorry for the noise.
wit-0.3.0-draft/types.wit
Outdated
headers: headers, | ||
body: option<body>, | ||
); | ||
contents: stream<u8>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment as for request
wit-0.3.0-draft/types.wit
Outdated
/// - a stream returned by a previous call to this method has reported itself as closed | ||
/// Thus there will always be at most one readable stream open for a given body. | ||
/// Each subsequent stream picks up where the last stream left off, up until it is finished. | ||
body: func() -> option<tuple<stream<u8>, future<result<option<trailers>, error-code>>>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment as for request
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, looks great with only requested remaining change to put option
on the content stream in the new
functions. Thanks for all the hard work implementing and thinking through all the important error and composition cases @rvolosatovs!
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
@lukewagner I've just pushed ae89575 |
Signed-off-by: Roman Volosatovs <rvolosatovs@riseup.net>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks great to me! The one open question (which I unfortunately didn't have earlier, my apologies!) is around the trailers
parameter to the request
/response
constructors—see inline comments.
headers: headers, | ||
body: option<body>, | ||
contents: option<stream<u8>>, | ||
trailers: future<result<option<trailers>, error-code>>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One thought on this: are there downsides to always having to pass a future here? I'm not sure what the overhead (both resource- and boilerplate-wise) of creating an managing a future is. If it's trivial, then this doesn't much matter, but otherwise, should we consider wrapping this into an option<>
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the "stream<T, U, V> world", if we go with optional body in constructors, the parameter would be option<stream<u8, option<trailers>, error-code>>
, meaning that users could either pass no body stream or pass a body stream, with potentially empty contents, but mandatory final element (option<trailers>
or error-code
).
From what I understand, there would not be a way to optionally pass a final stream element.
With that in mind, if we want to make the future optional, we'd need to take an option<tuple<stream<u8>, future<result<option<trailers>, error-code>>>>
, which would then directly translate to option<stream<u8, option<trailers>, error-code>>
in the future. (see also #162 (comment))
FWIW, here's what it takes to create a new future in Rust guest and pass to a constructor: https://github.com/bytecodealliance/wasip3-prototyping/blob/ad4ccf03172b22320c4a22971c51b4f7249a2846/crates/test-programs/src/p3/http.rs#L78-L81
and then send a value: https://github.com/bytecodealliance/wasip3-prototyping/blob/ad4ccf03172b22320c4a22971c51b4f7249a2846/crates/test-programs/src/p3/http.rs#L112
So 2 LoC, plus an import of futures::SinkExt
to expose futures::SinkExt::send
I would not expect creating a future handle to be an expensive operation, especially considering the fact that we're most likely dealing with network I/O here.
I'm happy to switch to a tuple
here, if that's what everyone prefers
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the overhead shouldn't be too significant. Also, it encourages the generally good engineering practice of explicitly signalling the final success or failure of the request
/response
in the result
.
/// - a stream returned by a previous call to this method has reported itself as closed | ||
/// Thus there will always be at most one readable stream open for a given body. | ||
/// Each subsequent stream picks up where the last stream left off, up until it is finished. | ||
body: func() -> result<tuple<stream<u8>, future<result<option<trailers>, error-code>>>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Depending on the answer to my question above, this might also have to change? Though even if we make the trailers
ctor parameter optional, I think we could leave this as-is, by constructing a future internally if none was passed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd rather not introduce a distinction here using the same reasoning as laid out by @lukewagner in #162 (comment)
If the future
was optional, users of this API would have no way of knowing if request/response receipt was successful if the future was none
.
E.g. if a component read one byte from stream<u8>
, after which it was closed and future
was none
- was the underlying I/O successful or was there a failure?
The future
resolving to a success means that the incoming request/response has been received it it's entirety. It seems that if it were optional, none
would be only possible in very niche use cases, e.g. if the resource was constructed by the guest and then requested by itself via body
.
I would rather avoid having to document/specify this behavior making this interface more self-documenting
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thank you for the explanation and references: that all makes sense to me!
Little update: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This turned out great!
Agreed; great work @rvolosatovs! |
future<result<option<trailers>, error-code>>
parameter in request/response constructors (refs Defererror-context
to after 0.3.0 component-model#474 (comment))headers
resources acquired by callingrequest.headers
orresponse.headers
to persist after respectiveinto-parts
is called, referencing the immutable headers of the original request/response. This implies that theheaders
acquired this way are not, if fact, children of the original request/response they originated from and the original request/response can be freely dropped and/or moved even before they are dropped.stream<u8, trailers, error-code>
into-parts
request-options#clone
Closes #156
Closes #161
Closes #164
Closes #163
Closes #165