From 7d7126bacb1c89e664baca48f591c900018e5937 Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Mon, 26 Oct 2026 18:01:21 +0100 Subject: [PATCH 01/10] feat(go)!: represent futures and streams as channels A `future` is now a `<-chan T`, which receives the value once the future resolves and is closed afterwards, and a `stream` is a `<-chan []T` of chunks, which is closed once the stream ends. The values cannot fail: as in Rust, a future never resolves after a failed read, and a stream ends. Readers of pending values return a deferred read alongside the value, like writers return a deferred write, and its error is reported on the client's error channel, renamed from `writeErrs__` to `errs__`, or logged by the server. The deferred read takes a `context.Context` and stops once it is done, so an unconsumed channel does not block it forever. The client reads with the invocation context, and the server cancels the context of the deferred parameter reads once the handler returned and the results were written. This also fixes `future`, `future>` and futures held by resources, which did not compile before. Assisted-by: claude:claude-opus-5-5 --- crates/wit-bindgen-go/src/interface.rs | 1102 ++++++----------- crates/wit-bindgen-test/src/go.rs | 4 +- examples/go/streams-client/app.go | 89 +- .../streams/handler/bindings.wrpc.go | 440 ++----- examples/go/streams-server/app.go | 4 +- .../streams/handler/bindings.wrpc.go | 581 ++------- .../wasi/keyvalue/store/bindings.wrpc.go | 33 +- .../wasi/keyvalue/store/bindings.wrpc.go | 33 +- go/future.go | 85 +- go/stream.go | 398 +++--- go/wrpc.go | 88 +- tests/go/async.go | 16 +- tests/go/async_test.go | 134 +- tests/go/internal/internal.go | 8 + 14 files changed, 999 insertions(+), 2016 deletions(-) diff --git a/crates/wit-bindgen-go/src/interface.rs b/crates/wit-bindgen-go/src/interface.rs index c797c40b0..a3b027f8b 100644 --- a/crates/wit-bindgen-go/src/interface.rs +++ b/crates/wit-bindgen-go/src/interface.rs @@ -10,7 +10,7 @@ use wit_bindgen_core::wit_parser::{ Variant, WorldKey, }; use wit_bindgen_core::{Source, TypeInfo, uwrite, uwriteln}; -use wrpc_introspect::{async_paths_ty, is_list_of, is_tuple, is_ty, rpc_func_name}; +use wrpc_introspect::{async_paths_ty, is_tuple, is_ty, rpc_func_name}; use crate::{ Deps, GoWrpc, Identifier, InterfaceName, to_go_ident, to_package_ident, to_upper_camel_case, @@ -578,8 +578,11 @@ impl InterfaceGenerator<'_> { let errors = self.deps.errors(); let slog = self.deps.slog(); let wrpc = self.deps.wrpc(); + let deferred = self.is_async(ty); + let d = if deferred { "nil, " } else { "" }; uwrite!(self.src, "func(r {wrpc}.IndexReadCloser, path ...uint32) ("); self.print_list(ty); + self.print_deferred_ret(deferred); uwrite!( self.src, r#", error) {{ @@ -592,41 +595,62 @@ impl InterfaceGenerator<'_> { if i > 0 && err == {io}.EOF {{ err = {io}.ErrUnexpectedEOF }} - return nil, {fmt}.Errorf("failed to read list length byte: %w", err) + return nil, {d}{fmt}.Errorf("failed to read list length byte: %w", err) }} if s == 28 && b > 0x0f {{ - return nil, {errors}.New("list length overflows a 32-bit integer") + return nil, {d}{errors}.New("list length overflows a 32-bit integer") }} if b < 0x80 {{ x = x | uint32(b)< { let fmt = self.deps.fmt(); let slog = self.deps.slog(); let wrpc = self.deps.wrpc(); + let deferred = self.is_async(ty); + let (d, dv) = if deferred { ("nil, ", "d, ") } else { ("", "") }; uwrite!(self.src, "func(r {wrpc}.IndexReadCloser, path ...uint32) ("); self.print_option(ty, true); + self.print_deferred_ret(deferred); uwrite!( self.src, r#", error) {{ {slog}.Debug("reading option status byte") status, err := r.ReadByte() if err != nil {{ - return nil, {fmt}.Errorf("failed to read option status byte: %w", err) + return nil, {d}{fmt}.Errorf("failed to read option status byte: %w", err) }} switch status {{ case 0: - return nil, nil + return nil, {d}nil case 1: {slog}.Debug("reading `option::some` payload") - v, err := "#, + v, {dv}err := "#, ); self.print_read_ty(ty, "r", "path"); self.push_str("\n"); uwrite!( self.src, r#"if err != nil {{ - return nil, {fmt}.Errorf("failed to read `option::some` value: %w", err) + return nil, {d}{fmt}.Errorf("failed to read `option::some` value: %w", err) }} return "#, ); self.print_nillable_ptr(ty, false, false); uwrite!( self.src, - r#"v, nil + r#"v, {dv}nil default: - return nil, {fmt}.Errorf("invalid option status byte %d", status) + return nil, {d}{fmt}.Errorf("invalid option status byte %d", status) }} }}({reader}"#, ); @@ -688,71 +715,65 @@ impl InterfaceGenerator<'_> { let fmt = self.deps.fmt(); let slog = self.deps.slog(); let wrpc = self.deps.wrpc(); + let deferred = ty.ok.iter().chain(&ty.err).any(|ty| self.is_async(ty)); + let d = if deferred { "nil, " } else { "" }; uwrite!( self.src, "func(r {wrpc}.IndexReadCloser, path ...uint32) (*" ); self.print_result(ty); + self.print_deferred_ret(deferred); uwriteln!( self.src, r#", error) {{ {slog}.Debug("reading result status byte") status, err := r.ReadByte() if err != nil {{ - return nil, {fmt}.Errorf("failed to read result status byte: %w", err) + return nil, {d}{fmt}.Errorf("failed to read result status byte: %w", err) }}"#, ); self.push_str("switch status {\n"); - self.push_str("case 0:\n"); - if let Some(ref ok) = ty.ok { - uwriteln!(self.src, r#"{slog}.Debug("reading `result::ok` payload")"#); - self.push_str("v, err := "); - self.print_read_ty(ok, "r", "path"); - self.push_str("\n"); - uwriteln!( - self.src, - r#"if err != nil {{ - return nil, fmt.Errorf("failed to read `result::ok` value: %w", err) - }}"#, - ); - self.push_str("return &"); - self.print_result(ty); - self.push_str("{ Ok: "); - self.print_nillable_ptr(ok, true, false); - } else { - self.push_str("var v struct{}\n"); - self.push_str("return &"); - self.print_result(ty); - self.push_str("{ Ok: &"); - } - self.push_str("v }, nil\n"); - self.push_str("case 1:\n"); - if let Some(ref err) = ty.err { - uwriteln!(self.src, r#"{slog}.Debug("reading `result::err` payload")"#); - self.push_str("v, err := "); - self.print_read_ty(err, "r", "path"); - self.push_str("\n"); - uwriteln!( - self.src, - r#"if err != nil {{ - return nil, {fmt}.Errorf("failed to read `result::err` value: %w", err) + for (i, (case, field, payload)) in [("ok", "Ok", &ty.ok), ("err", "Err", &ty.err)] + .into_iter() + .enumerate() + { + uwriteln!(self.src, "case {i}:"); + let mut ret = d; + if let Some(payload) = payload { + uwriteln!( + self.src, + r#"{slog}.Debug("reading `result::{case}` payload")"# + ); + if self.is_async(payload) { + self.push_str("v, d, err := "); + ret = "d, "; + } else { + self.push_str("v, err := "); + } + self.print_read_ty(payload, "r", "path"); + self.push_str("\n"); + uwriteln!( + self.src, + r#"if err != nil {{ + return nil, {d}{fmt}.Errorf("failed to read `result::{case}` value: %w", err) }}"#, - ); - self.push_str("return &"); - self.print_result(ty); - self.push_str("{ Err: "); - self.print_nillable_ptr(err, true, false); - } else { - self.push_str("var v struct{}\n"); - self.push_str("return &"); - self.print_result(ty); - self.push_str("{ Err: &"); + ); + self.push_str("return &"); + self.print_result(ty); + uwrite!(self.src, "{{ {field}: "); + self.print_nillable_ptr(payload, true, false); + } else { + self.push_str("var v struct{}\n"); + self.push_str("return &"); + self.print_result(ty); + uwrite!(self.src, "{{ {field}: &"); + } + uwriteln!(self.src, "v }}, {ret}nil"); } uwrite!( self.src, - r#"v }}, nil - default: - return nil, {fmt}.Errorf("invalid result status byte %d", status) + r#"default: + return nil, {d}{fmt}.Errorf("invalid result status byte %d", status) }} }}({reader}"# ); @@ -772,32 +793,57 @@ impl InterfaceGenerator<'_> { name: &str, ) { let wrpc = self.deps.wrpc(); - + let deferred = fields.iter().any(|Field { ty, .. }| self.is_async(ty)); + let d = if deferred { "nil, " } else { "" }; + uwrite!( + self.src, + "func(r {wrpc}.IndexReadCloser, path ...uint32) (*{name}" + ); + self.print_deferred_ret(deferred); uwriteln!( self.src, - r#"func(r {wrpc}.IndexReadCloser, path ...uint32) (*{name}, error) {{ + r#", error) {{ v := &{name}{{}} var err error"# ); + if deferred { + let context = self.deps.context(); + uwrite!( + self.src, + "var d func({context}.Context) error\nvar ds []func({context}.Context) error\n" + ); + } for (i, Field { name, ty, .. }) in fields.iter().enumerate() { let fmt = self.deps.fmt(); let slog = self.deps.slog(); let ident = name.to_upper_camel_case(); + let field_deferred = self.is_async(ty); uwrite!( self.src, r#"{slog}.Debug("reading field", "name", "{name}") - v.{ident}, err = "# + v.{ident}, "# ); + if field_deferred { + self.push_str("d, "); + } + self.push_str("err = "); self.print_read_ty(ty, "r", &format!("append(path, {i})")); self.push_str("\n"); uwriteln!( self.src, r#"if err != nil {{ - return nil, {fmt}.Errorf("failed to read `{name}` field: %w", err) + return nil, {d}{fmt}.Errorf("failed to read `{name}` field: %w", err) }}"# ); + if field_deferred { + self.push_str("ds = append(ds, d)\n"); + } + } + if deferred { + uwriteln!(self.src, "return v, {wrpc}.JoinDeferred(ds...), nil"); + } else { + self.push_str("return v, nil\n"); } - self.push_str("return v, nil\n"); uwrite!(self.src, "}}({reader}"); if !path.is_empty() { self.src.push_str(", "); @@ -864,19 +910,30 @@ impl InterfaceGenerator<'_> { fn print_read_variant(&mut self, ty: &Variant, reader: &str, path: &str, name: &str) { let fmt = self.deps.fmt(); let wrpc = self.deps.wrpc(); - + let deferred = ty + .cases + .iter() + .any(|Case { ty, .. }| ty.as_ref().is_some_and(|ty| self.is_async(ty))); + let d = if deferred { "nil, " } else { "" }; uwrite!( self.src, - r#"func(r {wrpc}.IndexReadCloser, path ...uint32) (*{name}, error) {{ + "func(r {wrpc}.IndexReadCloser, path ...uint32) (*{name}" + ); + self.print_deferred_ret(deferred); + uwrite!( + self.src, + r#", error) {{ v := &{name}{{}} n, err := "# ); self.print_read_discriminant(ty.tag(), "r"); self.push_str("\n"); - self.push_str("if err != nil {\n"); - self.push_str("return nil, "); - self.push_str(fmt); - self.push_str(".Errorf(\"failed to read discriminant: %w\", err)\n}\n"); + uwriteln!( + self.src, + r#"if err != nil {{ + return nil, {d}{fmt}.Errorf("failed to read discriminant: %w", err) + }}"# + ); uwriteln!(self.src, "switch {name}Discriminant(n) {{"); for Case { name: case_name, @@ -885,28 +942,29 @@ impl InterfaceGenerator<'_> { } in &ty.cases { let camel = case_name.to_upper_camel_case(); - self.push_str("case "); - self.push_str(name); - self.push_str(&camel); - self.push_str(":\n"); + uwriteln!(self.src, "case {name}{camel}:"); if let Some(ty) = ty { - self.push_str("payload, err := "); + let ret = if self.is_async(ty) { + self.push_str("payload, d, err := "); + "d, " + } else { + self.push_str("payload, err := "); + d + }; self.print_read_ty(ty, "r", "path"); self.push_str("\n"); - self.push_str("if err != nil { return nil, "); - self.push_str(fmt); uwriteln!( self.src, - r#".Errorf("failed to read `{case_name}` payload: %w", err) }}"# + r#"if err != nil {{ return nil, {d}{fmt}.Errorf("failed to read `{case_name}` payload: %w", err) }}"# ); - uwriteln!(self.src, "return v.Set{camel}(payload), nil"); + uwriteln!(self.src, "return v.Set{camel}(payload), {ret}nil"); } else { - uwriteln!(self.src, "return v.Set{camel}(), nil"); + uwriteln!(self.src, "return v.Set{camel}(), {d}nil"); } } uwriteln!( self.src, - r#"default: return nil, {fmt}.Errorf("unknown discriminant value %d", n) }}"# + r#"default: return nil, {d}{fmt}.Errorf("unknown discriminant value %d", n) }}"# ); uwrite!(self.src, "}}({reader}"); if !path.is_empty() { @@ -929,35 +987,56 @@ impl InterfaceGenerator<'_> { } _ => { let wrpc = self.deps.wrpc(); - + let deferred = ty.types.iter().any(|ty| self.is_async(ty)); + let d = if deferred { "nil, " } else { "" }; uwrite!( self.src, "func(r {wrpc}.IndexReadCloser, path ...uint32) (*" ); self.print_tuple(ty, true); + self.print_deferred_ret(deferred); self.push_str(", error) {\n"); self.push_str("v := &"); self.print_tuple(ty, false); self.push_str("{}\n"); self.push_str("var err error\n"); + if deferred { + let context = self.deps.context(); + uwrite!( + self.src, + "var d func({context}.Context) error\nvar ds []func({context}.Context) error\n" + ); + } for (i, ty) in ty.types.iter().enumerate() { let fmt = self.deps.fmt(); let slog = self.deps.slog(); + let elem_deferred = self.is_async(ty); uwrite!( self.src, r#"{slog}.Debug("reading tuple element {i}") - v.V{i}, err = "# + v.V{i}, "# ); + if elem_deferred { + self.push_str("d, "); + } + self.push_str("err = "); self.print_read_ty(ty, "r", &format!("append(path, {i})")); self.push_str("\n"); uwriteln!( self.src, r#"if err != nil {{ - return nil, {fmt}.Errorf("failed to read tuple element {i}: %w", err) + return nil, {d}{fmt}.Errorf("failed to read tuple element {i}: %w", err) }}"# ); + if elem_deferred { + self.push_str("ds = append(ds, d)\n"); + } + } + if deferred { + uwriteln!(self.src, "return v, {wrpc}.JoinDeferred(ds...), nil"); + } else { + self.push_str("return v, nil\n"); } - self.push_str("return v, nil\n"); uwrite!(self.src, "}}({reader}"); if !path.is_empty() { self.src.push_str(", "); @@ -970,286 +1049,56 @@ impl InterfaceGenerator<'_> { } fn print_read_future(&mut self, ty: &Option, reader: &str, path: &str) { - match ty { - Some(ty) if is_list_of(self.resolve, Type::U8, ty) => { - let bytes = self.deps.bytes(); - let io = self.deps.io(); - let fmt = self.deps.fmt(); - let slog = self.deps.slog(); - let wrpc = self.deps.wrpc(); - - uwriteln!( - self.src, - r#"func(r {wrpc}.IndexReadCloser, path ...uint32) ({io}.ReadCloser, error) {{ - {slog}.Debug("reading byte list future status byte") - status, err := r.ReadByte() - if err != nil {{ - return nil, {fmt}.Errorf("failed to read byte list future status byte: %w", err) - }} - switch status {{ - case 0: - {slog}.Debug("indexing pending byte list future reader") - if len(path) > 0 {{ - var err error - r, err = r.Index(path...) - if err != nil {{ - return nil, {fmt}.Errorf("failed to index nested byte list future reader: %w", err) - }} - }} - return {wrpc}.NewByteStreamReader(r), nil - case 1: - {slog}.Debug("reading ready byte list future contents") - buf, err := "# - ); - self.print_read_byte_list("r"); - uwrite!( - self.src, - r#" - if err != nil {{ - return nil, {fmt}.Errorf("failed to read ready byte list future contents: %w", err) - }} - {slog}.Debug("read ready byte list future contents", "len", len(buf)) - return {io}.NopCloser({bytes}.NewReader(buf)), nil - default: - return nil, {fmt}.Errorf("invalid byte list future status byte %d", status) - }} -}}({reader}"# - ); - if !path.is_empty() { - self.src.push_str(", "); - self.src.push_str(path); - self.src.push_str("..."); - } - self.src.push_str(")\n"); - } - Some(ty) => { - let fmt = self.deps.fmt(); - let slog = self.deps.slog(); - let wrpc = self.deps.wrpc(); - - uwrite!( - self.src, - r#"func(r {wrpc}.IndexReadCloser, path ...uint32) ({wrpc}.Receiver["# - ); - self.print_opt_ty(ty, true); - uwrite!( - self.src, - r#"], error) {{ - {slog}.Debug("reading future status byte") - status, err := r.ReadByte() - if err != nil {{ - return nil, {fmt}.Errorf("failed to read future status byte: %w", err) - }} - switch status {{ - case 0: - {slog}.Debug("indexing pending future reader") - if len(path) > 0 {{ - var err error - r, err = r.Index(path...) - if err != nil {{ - return nil, {fmt}.Errorf("failed to index nested future reader: %w", err) - }} - }} - return {wrpc}.NewDecodeReceiver(r, func(r {wrpc}.IndexReadCloser) ("# - ); - self.print_opt_ty(ty, true); - uwrite!( - self.src, - r#", error) {{ - {slog}.Debug("reading pending future element") - v, err := "# - ); - self.print_read_ty(ty, "r", ""); - uwriteln!( - self.src, - r#" - if err != nil {{ - return v, {fmt}.Errorf("failed to read pending future element: %w", err) - }} - return v, nil - }}), nil - case 1: - {slog}.Debug("reading ready future contents") - v, err := "# - ); - self.print_read_ty(ty, "r", "path"); - uwrite!( - self.src, - r#" - if err != nil {{ - return nil, {fmt}.Errorf("failed to read ready future contents: %w", err) - }} - return {wrpc}.NewCompleteReceiver(v), nil - default: - return nil, {fmt}.Errorf("invalid future status byte %d", status) - }} -}}({reader}"# - ); - if !path.is_empty() { - self.src.push_str(", "); - self.src.push_str(path); - self.src.push_str("..."); - } - self.src.push_str(")"); - } - None => panic!("futures with no element types are not supported"), + let Some(ty) = ty else { + panic!("futures with no element types are not supported") + }; + let wrpc = self.deps.wrpc(); + uwrite!(self.src, "{wrpc}.ReadFuture({reader}, "); + self.print_read_element(ty); + if !path.is_empty() { + self.src.push_str(", "); + self.src.push_str(path); + self.src.push_str("..."); } + self.src.push_str(")"); } - fn print_read_stream(&mut self, element: &Option, reader: &str, path: &str) { - match element { - Some(ty) if is_ty(self.resolve, Type::U8, ty) => { - let bytes = self.deps.bytes(); - let io = self.deps.io(); - let fmt = self.deps.fmt(); - let slog = self.deps.slog(); - let wrpc = self.deps.wrpc(); - - uwriteln!( - self.src, - r#"func(r {wrpc}.IndexReadCloser, path ...uint32) ({io}.ReadCloser, error) {{ - {slog}.Debug("reading byte stream status byte") - status, err := r.ReadByte() - if err != nil {{ - return nil, {fmt}.Errorf("failed to read byte stream status byte: %w", err) - }} - switch status {{ - case 0: - if len(path) > 0 {{ - var err error - r, err = r.Index(path...) - if err != nil {{ - return nil, {fmt}.Errorf("failed to index nested byte stream reader: %w", err) - }} - }} - return {wrpc}.NewByteStreamReader(r), nil - case 1: - {slog}.Debug("reading ready byte stream contents") - buf, err := "# - ); - self.print_read_byte_list("r"); - uwrite!( - self.src, - r#" - if err != nil {{ - return nil, {fmt}.Errorf("failed to read ready byte stream contents: %w", err) - }} - {slog}.Debug("read ready byte stream contents", "len", len(buf)) - return {io}.NopCloser({bytes}.NewReader(buf)), nil - default: - return nil, {fmt}.Errorf("invalid stream status byte %d", status) - }} -}}({reader}"# - ); - if !path.is_empty() { - self.src.push_str(", "); - self.src.push_str(path); - self.src.push_str("..."); - } - self.src.push_str(")"); - } - Some(ty) => { - let errors = self.deps.errors(); - let fmt = self.deps.fmt(); - let io = self.deps.io(); - let math = self.deps.math(); - let slog = self.deps.slog(); - let wrpc = self.deps.wrpc(); - - uwrite!( - self.src, - r#"func(r {wrpc}.IndexReadCloser, path ...uint32) ({wrpc}.Receiver["# - ); - self.print_list(ty); - uwrite!( - self.src, - r#"], error) {{ - {slog}.Debug("reading stream status byte") - status, err := r.ReadByte() - if err != nil {{ - return nil, {fmt}.Errorf("failed to read stream status byte: %w", err) - }} - switch status {{ - case 0: - if len(path) > 0 {{ - var err error - r, err = r.Index(path...) - if err != nil {{ - return nil, {fmt}.Errorf("failed to index nested stream reader: %w", err) - }} - }} - var total uint32 - return {wrpc}.NewDecodeReceiver(r, func(r {wrpc}.IndexReadCloser) ("# - ); - self.print_list(ty); - uwrite!( - self.src, - r#", error) {{ - {slog}.Debug("reading pending stream chunk length") - n, err := "# - ); - self.print_read_u32("r"); - uwrite!( - self.src, - r#" - if err != nil {{ - return nil, {fmt}.Errorf("failed to read pending stream chunk length: %w", err) - }} - if n == 0 {{ - return nil, {io}.EOF - }} - if {math}.MaxUint32 - n < total {{ - return nil, {errors}.New("total incoming pending stream element count would overflow a 32-bit unsigned integer") - }} - vs := {wrpc}.NewSlice["# - ); - self.print_list(ty); - uwrite!( - self.src, - r#"](n) - for i := range n {{ - {slog}.Debug("reading pending stream element", "i", total) - v, err := "# - ); - self.print_read_ty(ty, "r", "[]uint32{total}"); - uwriteln!( - self.src, - r#" - if err != nil {{ - return nil, {fmt}.Errorf("failed to read pending stream chunk element %d: %w", i, err) - }} - vs = append(vs, v) - total++ - }} - return vs, nil - }}), nil - case 1: - {slog}.Debug("reading ready stream contents") - vs, err := "# - ); - self.print_read_list(ty, "r", "path"); - uwrite!( - self.src, - r#" - if err != nil {{ - return nil, {fmt}.Errorf("failed to read ready stream contents: %w", err) - }} - {slog}.Debug("read ready stream contents", "len", len(vs)) - return {wrpc}.NewCompleteReceiver(vs), nil - default: - return nil, {fmt}.Errorf("invalid stream status byte %d", status) - }} -}}({reader}"# - ); - if !path.is_empty() { - self.src.push_str(", "); - self.src.push_str(path); - self.src.push_str("..."); - } - self.src.push_str(")"); + fn print_read_element(&mut self, ty: &Type) { + let wrpc = self.deps.wrpc(); + uwrite!(self.src, "func(r {wrpc}.IndexReadCloser, path ...uint32) ("); + self.print_opt_ty(ty, true); + let context = self.deps.context(); + uwrite!(self.src, ", func({context}.Context) error, error) {{\n"); + if self.is_async(ty) { + self.push_str("return "); + self.print_read_ty(ty, "r", "path"); + self.push_str("\n"); + } else { + self.push_str("v, err := "); + self.print_read_ty(ty, "r", "path"); + self.push_str("\nreturn v, nil, err\n"); + } + self.push_str("}"); + } + + fn print_read_stream(&mut self, element: &Option, reader: &str, path: &str) { + let wrpc = self.deps.wrpc(); + match element { + Some(ty) if is_ty(self.resolve, Type::U8, ty) => { + uwrite!(self.src, "{wrpc}.ReadByteStream({reader}"); + } + Some(ty) => { + uwrite!(self.src, "{wrpc}.ReadStream({reader}, "); + self.print_read_element(ty); } None => panic!("streams with no element types are not supported"), } + if !path.is_empty() { + self.src.push_str(", "); + self.src.push_str(path); + self.src.push_str("..."); + } + self.src.push_str(")"); } fn print_read_own(&mut self, reader: &str, id: TypeId) { @@ -1404,17 +1253,27 @@ impl InterfaceGenerator<'_> { TypeDefKind::Stream(ty) => self.print_read_stream(ty, reader, path), TypeDefKind::Type(ty) => { if let Some(name) = name { + let deferred = self.is_async(ty); self.push_str("func() ("); self.print_opt_ptr(ty, true); self.push_str(&name); + self.print_deferred_ret(deferred); self.push_str(", error) {\n"); - self.push_str("v, err :="); + if deferred { + self.push_str("v, d, err :="); + } else { + self.push_str("v, err :="); + } self.print_read_ty(ty, reader, path); self.push_str("\n"); self.push_str("return ("); self.print_opt_ptr(ty, true); self.push_str(&name); - self.push_str(")(v), err\n"); + if deferred { + self.push_str(")(v), d, err\n"); + } else { + self.push_str(")(v), err\n"); + } self.push_str("}()\n"); } else { self.print_read_ty(ty, reader, path); @@ -1734,7 +1593,7 @@ impl InterfaceGenerator<'_> { if len(writes) > 0 {{ return func(w {wrpc}.IndexWriter) error {{ var wg {sync}.WaitGroup - var wgErr {atomic}.Value + var wgErr {atomic}.Pointer[error] for index, write := range writes {{ wg.Add(1) w, err := w.Index(index) @@ -1745,16 +1604,15 @@ impl InterfaceGenerator<'_> { go func() {{ defer wg.Done() if err := write(w); err != nil {{ - wgErr.Store(err) + wgErr.Store(&err) }} }}() }} wg.Wait() - err := wgErr.Load() - if err == nil {{ - return nil + if err := wgErr.Load(); err != nil {{ + return *err }} - return err.(error) + return nil }}, nil }} return nil, nil @@ -1961,7 +1819,7 @@ impl InterfaceGenerator<'_> { r#"if len(writes) > 0 {{ return func(w {wrpc}.IndexWriter) error {{ var wg {sync}.WaitGroup - var wgErr {atomic}.Value + var wgErr {atomic}.Pointer[error] for index, write := range writes {{ wg.Add(1) w, err := w.Index(index) @@ -1972,16 +1830,15 @@ impl InterfaceGenerator<'_> { go func() {{ defer wg.Done() if err := write(w); err != nil {{ - wgErr.Store(err) + wgErr.Store(&err) }} }}() }} wg.Wait() - err := wgErr.Load() - if err == nil {{ - return nil + if err := wgErr.Load(); err != nil {{ + return *err }} - return err.(error) + return nil }}, nil }} return nil, nil @@ -1992,267 +1849,37 @@ impl InterfaceGenerator<'_> { } fn print_write_future(&mut self, ty: &Option, name: &str, writer: &str) { - match ty { - Some(ty) if is_list_of(self.resolve, Type::U8, ty) => { - let fmt = self.deps.fmt(); - let io = self.deps.io(); - let math = self.deps.math(); - let slog = self.deps.slog(); - let wrpc = self.deps.wrpc(); - uwrite!( - self.src, - r#"func(v {io}.Reader, w interface {{ {io}.ByteWriter; {io}.Writer }}) (write func({wrpc}.IndexWriter) error, err error) {{ - {slog}.Debug("writing byte list future `future::pending` status byte") - if err = w.WriteByte(0); err != nil {{ - return nil, fmt.Errorf("failed to write `future::pending` byte: %w", err) - }} - return func(w {wrpc}.IndexWriter) (err error) {{ - defer func() {{ - body, ok := v.({io}.Closer) - if ok {{ - {slog}.Debug("closing byte list future reader") - if cErr := body.Close(); cErr != nil {{ - if err == nil {{ - err = {fmt}.Errorf("failed to close pending byte list future: %w", cErr) - }} else {{ - {slog}.Warn("failed to close pending byte list future", "err", cErr) - }} - }} - }} - }}() - {slog}.Debug("reading pending byte list future contents") - chunk, err := {io}.ReadAll(chunk) - if err != nil {{ - return {fmt}.Errorf("failed to read pending byte list future: %w", err) - }} - if n > {math}.MaxUint32 {{ - return {fmt}.Errorf("pending byte list future length of %d overflows a 32-bit integer", n) - }} - {slog}.Debug("writing pending byte list future length", "len", n) - _, err = {wrpc}.WriteUint32(uint32(n), w) - if err != nil {{ - return {fmt}.Errorf("failed to write pending byte list future length of %d: %w", n, err) - }} - {slog}.Debug("writing pending byte list future contents", "buf", chunk[:n]) - _, err = w.Write(chunk[:n]) - if err != nil {{ - return {fmt}.Errorf("failed to write pending byte list future contents: %w", err) - }} - }}, nil - }}({name}, {writer})"#, - ); - } - Some(ty) => { - let fmt = self.deps.fmt(); - let io = self.deps.io(); - let slog = self.deps.slog(); - let wrpc = self.deps.wrpc(); - uwrite!(self.src, "func(v {wrpc}.Receiver[",); - self.print_opt_ty(ty, true); - uwrite!( - self.src, - r#"], w interface {{ {io}.ByteWriter; {io}.Writer }}) (write func({wrpc}.IndexWriter) error, err error) {{ - {slog}.Debug("writing future `future::pending` status byte") - if err := w.WriteByte(0); err != nil {{ - return nil, fmt.Errorf("failed to write `future::pending` byte: %w", err) - }} - return func(w {wrpc}.IndexWriter) (err error) {{ - defer func() {{ - {slog}.Debug("closing future writer") - if cErr := v.Close(); cErr != nil {{ - if err == nil {{ - err = {fmt}.Errorf("failed to close pending future: %w", cErr) - }} else {{ - {slog}.Warn("failed to close pending future", "err", cErr) - }} - }} - }}() - {slog}.Debug("receiving outgoing pending future contents") - rx, err := v.Receive() - if err != nil {{ - return {fmt}.Errorf("failed to receive outgoing pending future: %w", err) - }} - {slog}.Debug("writing pending future element") - write, err :="#, - ); - self.print_write_ty(ty, "rx", "w"); - uwrite!( - self.src, - r#" - if err != nil {{ - return {fmt}.Errorf("failed to write pending future element: %w", err) - }} - if write != nil {{ - return write(w) - }} - return nil - }}, nil - }}({name}, {writer})"#, - ); - } - None => panic!("streams with no element types are not supported"), - } + let Some(ty) = ty else { + panic!("futures with no element types are not supported") + }; + let wrpc = self.deps.wrpc(); + uwrite!(self.src, "{wrpc}.WriteFuture({name}, {writer}, "); + self.print_write_element(ty); + self.push_str(")"); + } + + fn print_write_element(&mut self, ty: &Type) { + let wrpc = self.deps.wrpc(); + self.push_str("func(v "); + self.print_opt_ty(ty, true); + uwrite!( + self.src, + ", w {wrpc}.ByteWriter) (func({wrpc}.IndexWriter) error, error) {{\nreturn " + ); + self.print_write_ty(ty, "v", "w"); + self.push_str("\n}"); } fn print_write_stream(&mut self, element: &Option, name: &str, writer: &str) { + let wrpc = self.deps.wrpc(); match element { Some(ty) if is_ty(self.resolve, Type::U8, ty) => { - let fmt = self.deps.fmt(); - let io = self.deps.io(); - let math = self.deps.math(); - let slog = self.deps.slog(); - let wrpc = self.deps.wrpc(); - uwrite!( - self.src, - r#"func(v {io}.ReadCloser, w interface {{ {io}.ByteWriter; {io}.Writer }}) (write func({wrpc}.IndexWriter) error, err error) {{ - {slog}.Debug("writing byte stream `stream::pending` status byte") - if err = w.WriteByte(0); err != nil {{ - return nil, fmt.Errorf("failed to write `stream::pending` byte: %w", err) - }} - return func(w {wrpc}.IndexWriter) (err error) {{ - defer func() {{ - {slog}.Debug("closing byte list stream writer") - if cErr := v.Close(); cErr != nil {{ - if err == nil {{ - err = {fmt}.Errorf("failed to close pending byte stream: %w", cErr) - }} else {{ - {slog}.Warn("failed to close pending byte stream", "err", cErr) - }} - }} - }}() - chunk := make([]byte, {wrpc}.ReadChunkSize) - for {{ - var end bool - {slog}.Debug("reading pending byte stream contents") - n, err := v.Read(chunk) - if err == {io}.EOF {{ - end = true - {slog}.Debug("pending byte stream reached EOF") - }} else if err != nil {{ - return {fmt}.Errorf("failed to read pending byte stream chunk: %w", err) - }} - if n > {math}.MaxUint32 {{ - return {fmt}.Errorf("pending byte stream chunk length of %d overflows a 32-bit integer", n) - }} - if n > 0 {{ - {slog}.Debug("writing pending byte stream chunk length", "len", n) - _, err = {wrpc}.WriteUint32(uint32(n), w) - if err != nil {{ - return {fmt}.Errorf("failed to write pending byte stream chunk length of %d: %w", n, err) - }} - _, err = w.Write(chunk[:n]) - if err != nil {{ - return {fmt}.Errorf("failed to write pending byte stream chunk contents: %w", err) - }} - }} - if end {{ - if err := w.WriteByte(0); err != nil {{ - return {fmt}.Errorf("failed to write pending byte stream end byte: %w", err) - }} - return nil - }} - }} - }}, nil - }}({name}, {writer})"#, - ); + uwrite!(self.src, "{wrpc}.WriteByteStream({name}, {writer})"); } Some(ty) => { - let atomic = self.deps.atomic(); - let errors = self.deps.errors(); - let fmt = self.deps.fmt(); - let io = self.deps.io(); - let math = self.deps.math(); - let slog = self.deps.slog(); - let sync = self.deps.sync(); - let wrpc = self.deps.wrpc(); - uwrite!(self.src, "func(v {wrpc}.Receiver[",); - self.print_list(ty); - uwrite!( - self.src, - r#"], w interface {{ {io}.ByteWriter; {io}.Writer }}) (write func({wrpc}.IndexWriter) error, err error) {{ - {slog}.Debug("writing stream `stream::pending` status byte") - if err := w.WriteByte(0); err != nil {{ - return nil, fmt.Errorf("failed to write `stream::pending` byte: %w", err) - }} - return func(w {wrpc}.IndexWriter) (err error) {{ - defer func() {{ - {slog}.Debug("closing outgoing pending stream") - if cErr := v.Close(); cErr != nil {{ - if err == nil {{ - err = {fmt}.Errorf("failed to close outgoing pending stream: %w", cErr) - }} else {{ - {slog}.Warn("failed to close outgoing pending stream", "err", cErr) - }} - }} - }}() - var wg {sync}.WaitGroup - var wgErr {atomic}.Value - var total uint32 - for {{ - var end bool - {slog}.Debug("receiving outgoing pending stream contents") - chunk, err := v.Receive() - n := len(chunk) - if err == {io}.EOF {{ - end = true - {slog}.Debug("outgoing pending stream reached EOF") - }} else if err != nil {{ - return {fmt}.Errorf("failed to receive outgoing pending stream chunk: %w", err) - }} - if n > {math}.MaxUint32 {{ - return {fmt}.Errorf("outgoing pending stream chunk length of %d overflows a 32-bit integer", n) - }} - if {math}.MaxUint32 - uint32(n) < total {{ - return {errors}.New("total outgoing pending stream element count would overflow a 32-bit unsigned integer") - }} - if n > 0 {{ - {slog}.Debug("writing pending stream chunk length", "len", n) - _, err = {wrpc}.WriteUint32(uint32(n), w) - if err != nil {{ - return {fmt}.Errorf("failed to write pending stream chunk length of %d: %w", n, err) - }} - for _, v := range chunk {{ - {slog}.Debug("writing pending stream element", "i", total) - write, err :="#, - ); - self.print_write_ty(ty, "v", "w"); - uwrite!( - self.src, - r#" - if err != nil {{ - return {fmt}.Errorf("failed to write pending stream chunk element %d: %w", total, err) - }} - if write != nil {{ - wg.Add(1) - w, err := w.Index(total) - if err != nil {{ - return {fmt}.Errorf("failed to index nested stream writer: %w", err) - }} - go func() {{ - defer wg.Done() - if err := write(w); err != nil {{ - wgErr.Store(err) - }} - }}() - }} - total++ - }} - }} - if end {{ - if err := w.WriteByte(0); err != nil {{ - return {fmt}.Errorf("failed to write pending stream end byte: %w", err) - }} - wg.Wait() - err := wgErr.Load() - if err == nil {{ - return nil - }} - return err.(error) - }} - }} - }}, nil - }}({name}, {writer})"#, - ); + uwrite!(self.src, "{wrpc}.WriteStream({name}, {writer}, "); + self.print_write_element(ty); + self.push_str(")"); } None => panic!("streams with no element types are not supported"), } @@ -2566,12 +2193,33 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { }} }}()"#, ); + let async_params = func.params.iter().any(|Param { ty, .. }| self.is_async(ty)); + if async_params { + let sync = self.deps.sync(); + uwrite!( + self.src, + r#" + ctx, cancel := {context}.WithCancel(ctx) + var wg {sync}.WaitGroup + defer func() {{ + go func() {{ + wg.Wait() + cancel() + }}() + }}()"#, + ); + } for (i, Param { ty, .. }) in func.params.iter().enumerate() { + let d = if self.is_async(ty) { + format!("d{i}, ") + } else { + String::new() + }; uwrite!( self.src, r#" {slog}.DebugContext(ctx, "reading parameter", "i", {i}) - p{i}, err := "# + p{i}, {d}err := "# ); self.print_read_ty(ty, "r", &format!("[]uint32{{ {i} }}")); self.push_str("\n"); @@ -2587,6 +2235,21 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { }}"#, ); } + for (i, Param { ty, .. }) in func.params.iter().enumerate() { + if self.is_async(ty) { + uwrite!( + self.src, + r#" + if d{i} != nil {{ + go func() {{ + if err := d{i}(ctx); err != nil {{ + {slog}.WarnContext(ctx, "failed to read deferred parameter", "i", {i}, "instance", "{instance}", "name", "{name}", "err", err) + }} + }}() + }}"#, + ); + } + } uwriteln!( self.src, r#" @@ -2678,8 +2341,27 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { {slog}.ErrorContext(ctx, "failed to index result writer", "instance", "{instance}", "name", "{name}", "err", err) return }} - write := write - go func() {{ + write := write"#, + ); + if async_params { + self.push_str( + r#" + wg.Add(1)"#, + ); + } + self.push_str( + r#" + go func() {"#, + ); + if async_params { + self.push_str( + r#" + defer wg.Done()"#, + ); + } + uwrite!( + self.src, + r#" if err := write(w); err != nil {{ {slog}.WarnContext(ctx, "failed to write nested result value", "instance", "{instance}", "name", "{name}", "err", err) }} @@ -2758,12 +2440,10 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { self.src.push_str(", "); } - let async_params = func.params.iter().any(|param| { - let (paths, fut) = async_paths_ty(self.resolve, ¶m.ty); - fut || !paths.is_empty() - }); - if async_params { - self.push_str("writeErrs__ <-chan error, "); + let async_params = func.params.iter().any(|param| self.is_async(¶m.ty)); + let async_results = results.iter().filter(|ty| self.is_async(ty)).count(); + if async_params || async_results > 0 { + self.push_str("errs__ <-chan error, "); } self.push_str("err__ error) {"); if !func.params.is_empty() { @@ -2888,17 +2568,33 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { }}()"#, name = func.name, ); - if async_params { + if async_params || async_results > 0 { let sync = self.deps.sync(); + let count = if async_params { + format!("uint(writeCount__) + {async_results}") + } else { + async_results.to_string() + }; + uwrite!( + self.src, + r#" + errCh__ := make(chan error, {count}) + errs__ = errCh__ + var wg__ {sync}.WaitGroup + defer func() {{ + go func() {{ + wg__.Wait() + close(errCh__) + }}() + }}()"# + ); + } + if async_params { uwrite!( self.src, r#" if writeCount__ > 0 {{ - writeErrCh__ := make(chan error, uint(writeCount__)) - writeErrs__ = writeErrCh__ - var wg__ {sync}.WaitGroup for index, write := range writes__ {{ - wg__.Add(1) w, err := w__.Index(index) if err != nil {{ if cErr := w__.Close(); cErr != nil {{ @@ -2907,18 +2603,15 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { err__ = {fmt}.Errorf("failed to index param writer at index `%v`: %w", index, err) return }} + wg__.Add(1) write := write go func() {{ defer wg__.Done() if err := write(w); err != nil {{ - writeErrCh__ <- err + errCh__ <- err }} }}() }} - go func() {{ - wg__.Wait() - close(writeErrCh__) - }}() }}"#, func.name, ); @@ -2933,13 +2626,25 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { ); let mut idx = 0usize; + let mut deferreds = Vec::default(); for (i, rty) in func.result.iter().enumerate() { for (j, ty) in flatten_ty(self.resolve, rty).enumerate() { - uwrite!( - self.src, - " + let deferred = self.is_async(&ty); + if deferred { + let context = self.deps.context(); + uwrite!( + self.src, + " + var d{idx}__ func({context}.Context) error + r{idx}__, d{idx}__, err__ = " + ); + } else { + uwrite!( + self.src, + " r{idx}__, err__ = " - ); + ); + } let path = if is_tuple(self.resolve, rty) { format!("[]uint32{{ {i}, {j} }}") } else { @@ -2954,9 +2659,27 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { return }}"#, ); + if deferred { + deferreds.push(idx); + } idx = idx.saturating_add(1); } } + for idx in deferreds { + uwrite!( + self.src, + r#" + if d{idx}__ != nil {{ + wg__.Add(1) + go func() {{ + defer wg__.Done() + if err := d{idx}__(ctx__); err != nil {{ + errCh__ <- err + }} + }}() + }}"#, + ); + } if func.result.is_none() { // A function with no results still completes with an (empty) // result transmission; await it so the call is synchronous and @@ -3137,6 +2860,18 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { name } + fn print_deferred_ret(&mut self, deferred: bool) { + if deferred { + let context = self.deps.context(); + uwrite!(self.src, ", func({context}.Context) error"); + } + } + + fn is_async(&self, ty: &Type) -> bool { + let (paths, fut) = async_paths_ty(self.resolve, ty); + fut || !paths.is_empty() + } + fn nillable_ptr(&self, ty: &Type, result: bool, decl: bool) -> &'static str { if let Type::Id(id) = ty { match &self.resolve.types[*id].kind { @@ -3248,7 +2983,7 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { && let TypeDefKind::List(..) | TypeDefKind::Future(..) | TypeDefKind::Stream(..) = self.resolve.types[*id].kind { - // Go slices, channels and interfaces are nillable + // Go slices and channels are nillable self.print_ty(ty, true); return; } @@ -3294,43 +3029,19 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { } fn print_future(&mut self, ty: &Option) { - match ty { - Some(ty) if is_ty(self.resolve, Type::U8, ty) => { - let io = self.deps.io(); - self.push_str(io); - self.push_str(".ReadCloser"); - } - Some(ty) => { - let wrpc = self.deps.wrpc(); - self.push_str(wrpc); - self.push_str(".Receiver["); - self.print_opt_ty(ty, true); - self.push_str("]"); - } - None => { - panic!("futures with no element types are not supported") - } - } + let Some(ty) = ty else { + panic!("futures with no element types are not supported") + }; + self.push_str("<-chan "); + self.print_opt_ty(ty, true); } fn print_stream(&mut self, element: &Option) { - match element { - Some(ty) if is_ty(self.resolve, Type::U8, ty) => { - let io = self.deps.io(); - self.push_str(io); - self.push_str(".ReadCloser"); - } - Some(ty) => { - let wrpc = self.deps.wrpc(); - self.push_str(wrpc); - self.push_str(".Receiver["); - self.print_list(ty); - self.push_str("]"); - } - None => { - panic!("streams with no element types are not supported") - } - } + let Some(ty) = element else { + panic!("streams with no element types are not supported") + }; + self.push_str("<-chan "); + self.print_list(ty); } fn print_own(&mut self, id: TypeId) { @@ -3485,7 +3196,7 @@ func (v *{name}) WriteToIndex(w {wrpc}.ByteWriter) (func({wrpc}.IndexWriter) err if len(writes) > 0 {{ return func(w {wrpc}.IndexWriter) error {{ var wg {sync}.WaitGroup - var wgErr {atomic}.Value + var wgErr {atomic}.Pointer[error] for index, write := range writes {{ wg.Add(1) w, err := w.Index(index) @@ -3496,16 +3207,15 @@ func (v *{name}) WriteToIndex(w {wrpc}.ByteWriter) (func({wrpc}.IndexWriter) err go func() {{ defer wg.Done() if err := write(w); err != nil {{ - wgErr.Store(err) + wgErr.Store(&err) }} }}() }} wg.Wait() - err := wgErr.Load() - if err == nil {{ - return nil + if err := wgErr.Load(); err != nil {{ + return *err }} - return err.(error) + return nil }}, nil }} return nil, nil diff --git a/crates/wit-bindgen-test/src/go.rs b/crates/wit-bindgen-test/src/go.rs index 16a87677b..f85a20c76 100644 --- a/crates/wit-bindgen-test/src/go.rs +++ b/crates/wit-bindgen-test/src/go.rs @@ -35,14 +35,12 @@ impl LanguageMethods for Go { _args: &[String], ) -> bool { // The wRPC Go generator does not yet support bare `stream`/`future` - // types, `error-context`, or futures held by resources. + // types, `error-context`, named fixed-length lists, or `map`. matches!( name, "streams.wit" | "futures.wit" | "error-context.wit" - | "resources-with-futures.wit" - | "future-same-type-different-names.wit" | "named-fixed-length-list.wit" | "map.wit" ) diff --git a/examples/go/streams-client/app.go b/examples/go/streams-client/app.go index dfa3c0d90..dc92731dd 100644 --- a/examples/go/streams-client/app.go +++ b/examples/go/streams-client/app.go @@ -5,7 +5,6 @@ package app import ( "context" "fmt" - "io" "log/slog" "os" "sync" @@ -26,57 +25,24 @@ func init() { }))) } -type ThrottleStream[T any] struct { - tick *time.Ticker - values []T -} - -func (s *ThrottleStream[T]) Receive() ([]T, error) { - if len(s.values) == 0 { - return nil, io.EOF - } - <-s.tick.C - var v T - v, s.values = s.values[0], s.values[1:] - return []T{v}, nil -} - -func (s *ThrottleStream[T]) Close() error { - s.tick.Stop() - return nil -} - -type ThrottleReader struct { - tick *time.Ticker - buf []byte -} - -func (s *ThrottleReader) Read(p []byte) (int, error) { - if len(s.buf) == 0 { - return 0, io.EOF - } - <-s.tick.C - var b byte - b, s.buf = s.buf[0], s.buf[1:] - p[0] = b - return 1, nil -} - -func (s *ThrottleReader) Close() error { - s.tick.Stop() - return nil +func throttle[T any](values []T, d time.Duration) <-chan []T { + ch := make(chan []T) + go func() { + defer close(ch) + tick := time.NewTicker(d) + defer tick.Stop() + for _, v := range values { + <-tick.C + ch <- []T{v} + } + }() + return ch } func Run(prefix string, client wrpc.Invoker) error { numbers, bytes, errCh, err := handler.Echo(context.Background(), client, &handler.Req{ - Numbers: &ThrottleStream[uint64]{ - tick: time.NewTicker(time.Second), - values: []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, - }, - Bytes: &ThrottleReader{ - tick: time.NewTicker(time.Second), - buf: []byte("foo bar baz"), - }, + Numbers: throttle([]uint64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, time.Second), + Bytes: throttle([]byte("foo bar baz"), time.Second), }) if err != nil { return fmt.Errorf("failed to call `wrpc-examples:streams/handler.echo`: %w", err) @@ -87,38 +53,19 @@ func Run(prefix string, client wrpc.Invoker) error { wg.Add(1) go func() { defer wg.Done() - defer numbers.Close() - for { - chunk, err := numbers.Receive() - if err == io.EOF { - return - } - if err != nil { - slog.Error("failed to receive number chunk", "err", err) - return - } + for chunk := range numbers { fmt.Printf("%s numbers: %v\n", prefix, chunk) } }() wg.Add(1) go func() { defer wg.Done() - defer bytes.Close() - var chunk [128]byte - for { - n, err := bytes.Read(chunk[:]) - if err == io.EOF { - return - } - if err != nil { - slog.Error("failed to receive byte chunk", "err", err) - return - } - fmt.Printf("%s bytes: %s\n", prefix, chunk[:n]) + for chunk := range bytes { + fmt.Printf("%s bytes: %s\n", prefix, chunk) } }() for err := range errCh { - slog.Error("failed to transmit async parameters", "err", err) + slog.Error("failed to exchange async values", "err", err) } return nil } diff --git a/examples/go/streams-client/bindings/wrpc_examples/streams/handler/bindings.wrpc.go b/examples/go/streams-client/bindings/wrpc_examples/streams/handler/bindings.wrpc.go index 881eb6496..fb859f1c8 100644 --- a/examples/go/streams-client/bindings/wrpc_examples/streams/handler/bindings.wrpc.go +++ b/examples/go/streams-client/bindings/wrpc_examples/streams/handler/bindings.wrpc.go @@ -9,15 +9,14 @@ import ( fmt "fmt" io "io" slog "log/slog" - math "math" sync "sync" atomic "sync/atomic" wrpc "wrpc.io/go" ) type Req struct { - Numbers wrpc.Receiver[[]uint64] - Bytes io.ReadCloser + Numbers <-chan []uint64 + Bytes <-chan []uint8 } func (v *Req) String() string { return "Req" } @@ -25,93 +24,15 @@ func (v *Req) String() string { return "Req" } func (v *Req) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) error, error) { writes := make(map[uint32]func(wrpc.IndexWriter) error, 2) slog.Debug("writing field", "name", "numbers") - write0, err := func(v wrpc.Receiver[[]uint64], w interface { - io.ByteWriter - io.Writer - }) (write func(wrpc.IndexWriter) error, err error) { - slog.Debug("writing stream `stream::pending` status byte") - if err := w.WriteByte(0); err != nil { - return nil, fmt.Errorf("failed to write `stream::pending` byte: %w", err) - } - return func(w wrpc.IndexWriter) (err error) { - defer func() { - slog.Debug("closing outgoing pending stream") - if cErr := v.Close(); cErr != nil { - if err == nil { - err = fmt.Errorf("failed to close outgoing pending stream: %w", cErr) - } else { - slog.Warn("failed to close outgoing pending stream", "err", cErr) - } - } - }() - var wg sync.WaitGroup - var wgErr atomic.Value - var total uint32 - for { - var end bool - slog.Debug("receiving outgoing pending stream contents") - chunk, err := v.Receive() - n := len(chunk) - if err == io.EOF { - end = true - slog.Debug("outgoing pending stream reached EOF") - } else if err != nil { - return fmt.Errorf("failed to receive outgoing pending stream chunk: %w", err) - } - if n > math.MaxUint32 { - return fmt.Errorf("outgoing pending stream chunk length of %d overflows a 32-bit integer", n) - } - if math.MaxUint32-uint32(n) < total { - return errors.New("total outgoing pending stream element count would overflow a 32-bit unsigned integer") - } - if n > 0 { - slog.Debug("writing pending stream chunk length", "len", n) - _, err = wrpc.WriteUint32(uint32(n), w) - if err != nil { - return fmt.Errorf("failed to write pending stream chunk length of %d: %w", n, err) - } - for _, v := range chunk { - slog.Debug("writing pending stream element", "i", total) - write, err := (func(wrpc.IndexWriter) error)(nil), func(v uint64, w io.Writer) (err error) { - b := make([]byte, binary.MaxVarintLen64) - i := binary.PutUvarint(b, uint64(v)) - slog.Debug("writing u64") - _, err = w.Write(b[:i]) - return err - }(v, w) - if err != nil { - return fmt.Errorf("failed to write pending stream chunk element %d: %w", total, err) - } - if write != nil { - wg.Add(1) - w, err := w.Index(total) - if err != nil { - return fmt.Errorf("failed to index nested stream writer: %w", err) - } - go func() { - defer wg.Done() - if err := write(w); err != nil { - wgErr.Store(err) - } - }() - } - total++ - } - } - if end { - if err := w.WriteByte(0); err != nil { - return fmt.Errorf("failed to write pending stream end byte: %w", err) - } - wg.Wait() - err := wgErr.Load() - if err == nil { - return nil - } - return err.(error) - } - } - }, nil - }(v.Numbers, w) + write0, err := wrpc.WriteStream(v.Numbers, w, func(v uint64, w wrpc.ByteWriter) (func(wrpc.IndexWriter) error, error) { + return (func(wrpc.IndexWriter) error)(nil), func(v uint64, w io.Writer) (err error) { + b := make([]byte, binary.MaxVarintLen64) + i := binary.PutUvarint(b, uint64(v)) + slog.Debug("writing u64") + _, err = w.Write(b[:i]) + return err + }(v, w) + }) if err != nil { return nil, fmt.Errorf("failed to write `numbers` field: %w", err) } @@ -119,59 +40,7 @@ func (v *Req) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) error, err writes[0] = write0 } slog.Debug("writing field", "name", "bytes") - write1, err := func(v io.ReadCloser, w interface { - io.ByteWriter - io.Writer - }) (write func(wrpc.IndexWriter) error, err error) { - slog.Debug("writing byte stream `stream::pending` status byte") - if err = w.WriteByte(0); err != nil { - return nil, fmt.Errorf("failed to write `stream::pending` byte: %w", err) - } - return func(w wrpc.IndexWriter) (err error) { - defer func() { - slog.Debug("closing byte list stream writer") - if cErr := v.Close(); cErr != nil { - if err == nil { - err = fmt.Errorf("failed to close pending byte stream: %w", cErr) - } else { - slog.Warn("failed to close pending byte stream", "err", cErr) - } - } - }() - chunk := make([]byte, wrpc.ReadChunkSize) - for { - var end bool - slog.Debug("reading pending byte stream contents") - n, err := v.Read(chunk) - if err == io.EOF { - end = true - slog.Debug("pending byte stream reached EOF") - } else if err != nil { - return fmt.Errorf("failed to read pending byte stream chunk: %w", err) - } - if n > math.MaxUint32 { - return fmt.Errorf("pending byte stream chunk length of %d overflows a 32-bit integer", n) - } - if n > 0 { - slog.Debug("writing pending byte stream chunk length", "len", n) - _, err = wrpc.WriteUint32(uint32(n), w) - if err != nil { - return fmt.Errorf("failed to write pending byte stream chunk length of %d: %w", n, err) - } - _, err = w.Write(chunk[:n]) - if err != nil { - return fmt.Errorf("failed to write pending byte stream chunk contents: %w", err) - } - } - if end { - if err := w.WriteByte(0); err != nil { - return fmt.Errorf("failed to write pending byte stream end byte: %w", err) - } - return nil - } - } - }, nil - }(v.Bytes, w) + write1, err := wrpc.WriteByteStream(v.Bytes, w) if err != nil { return nil, fmt.Errorf("failed to write `bytes` field: %w", err) } @@ -182,7 +51,7 @@ func (v *Req) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) error, err if len(writes) > 0 { return func(w wrpc.IndexWriter) error { var wg sync.WaitGroup - var wgErr atomic.Value + var wgErr atomic.Pointer[error] for index, write := range writes { wg.Add(1) w, err := w.Index(index) @@ -193,21 +62,20 @@ func (v *Req) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) error, err go func() { defer wg.Done() if err := write(w); err != nil { - wgErr.Store(err) + wgErr.Store(&err) } }() } wg.Wait() - err := wgErr.Load() - if err == nil { - return nil + if err := wgErr.Load(); err != nil { + return *err } - return err.(error) + return nil }, nil } return nil, nil } -func Echo(ctx__ context.Context, wrpc__ wrpc.Invoker, r *Req) (r0__ wrpc.Receiver[[]uint64], r1__ io.ReadCloser, writeErrs__ <-chan error, err__ error) { +func Echo(ctx__ context.Context, wrpc__ wrpc.Invoker, r *Req) (r0__ <-chan []uint64, r1__ <-chan []uint8, errs__ <-chan error, err__ error) { var buf__ bytes.Buffer var writeCount__ uint32 write0__, err__ := (r).WriteToIndex(&buf__) @@ -236,12 +104,17 @@ func Echo(ctx__ context.Context, wrpc__ wrpc.Invoker, r *Req) (r0__ wrpc.Receive slog.ErrorContext(ctx__, "failed to close reader", "instance", "wrpc-examples:streams/handler", "name", "echo", "err", err) } }() + errCh__ := make(chan error, uint(writeCount__)+2) + errs__ = errCh__ + var wg__ sync.WaitGroup + defer func() { + go func() { + wg__.Wait() + close(errCh__) + }() + }() if writeCount__ > 0 { - writeErrCh__ := make(chan error, uint(writeCount__)) - writeErrs__ = writeErrCh__ - var wg__ sync.WaitGroup for index, write := range writes__ { - wg__.Add(1) w, err := w__.Index(index) if err != nil { if cErr := w__.Close(); cErr != nil { @@ -250,246 +123,73 @@ func Echo(ctx__ context.Context, wrpc__ wrpc.Invoker, r *Req) (r0__ wrpc.Receive err__ = fmt.Errorf("failed to index param writer at index `%v`: %w", index, err) return } + wg__.Add(1) write := write go func() { defer wg__.Done() if err := write(w); err != nil { - writeErrCh__ <- err + errCh__ <- err } }() } - go func() { - wg__.Wait() - close(writeErrCh__) - }() } if cErr__ := w__.Close(); cErr__ != nil { slog.DebugContext(ctx__, "failed to close outgoing stream", "instance", "wrpc-examples:streams/handler", "name", "echo", "err", cErr__) } - r0__, err__ = func(r wrpc.IndexReadCloser, path ...uint32) (wrpc.Receiver[[]uint64], error) { - slog.Debug("reading stream status byte") - status, err := r.ReadByte() - if err != nil { - return nil, fmt.Errorf("failed to read stream status byte: %w", err) - } - switch status { - case 0: - if len(path) > 0 { - var err error - r, err = r.Index(path...) + var d0__ func(context.Context) error + r0__, d0__, err__ = wrpc.ReadStream(r__, func(r wrpc.IndexReadCloser, path ...uint32) (uint64, func(context.Context) error, error) { + v, err := func(r io.ByteReader) (uint64, error) { + var x uint64 + var s uint8 + for i := 0; i < 10; i++ { + slog.Debug("reading u64 byte", "i", i) + b, err := r.ReadByte() if err != nil { - return nil, fmt.Errorf("failed to index nested stream reader: %w", err) - } - } - var total uint32 - return wrpc.NewDecodeReceiver(r, func(r wrpc.IndexReadCloser) ([]uint64, error) { - slog.Debug("reading pending stream chunk length") - n, err := func(r io.ByteReader) (uint32, error) { - var x uint32 - var s uint8 - for i := 0; i < 5; i++ { - slog.Debug("reading u32 byte", "i", i) - b, err := r.ReadByte() - if err != nil { - if i > 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return x, fmt.Errorf("failed to read u32 byte: %w", err) - } - if s == 28 && b > 0x0f { - return x, errors.New("varint overflows a 32-bit integer") - } - if b < 0x80 { - return x | uint32(b)< 0 && err == io.EOF { + err = io.ErrUnexpectedEOF } - return x, errors.New("varint overflows a 32-bit integer") - }(r) - if err != nil { - return nil, fmt.Errorf("failed to read pending stream chunk length: %w", err) - } - if n == 0 { - return nil, io.EOF + return x, fmt.Errorf("failed to read u64 byte: %w", err) } - if math.MaxUint32-n < total { - return nil, errors.New("total incoming pending stream element count would overflow a 32-bit unsigned integer") + if s == 63 && b > 0x01 { + return x, errors.New("varint overflows a 64-bit integer") } - vs := wrpc.NewSlice[[]uint64](n) - for i := range n { - slog.Debug("reading pending stream element", "i", total) - v, err := func(r io.ByteReader) (uint64, error) { - var x uint64 - var s uint8 - for i := 0; i < 10; i++ { - slog.Debug("reading u64 byte", "i", i) - b, err := r.ReadByte() - if err != nil { - if i > 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return x, fmt.Errorf("failed to read u64 byte: %w", err) - } - if s == 63 && b > 0x01 { - return x, errors.New("varint overflows a 64-bit integer") - } - if b < 0x80 { - return x | uint64(b)< 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return nil, fmt.Errorf("failed to read list length byte: %w", err) - } - if s == 28 && b > 0x0f { - return nil, errors.New("list length overflows a 32-bit integer") - } - if b < 0x80 { - x = x | uint32(b)< 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return x, fmt.Errorf("failed to read u64 byte: %w", err) - } - if s == 63 && b > 0x01 { - return x, errors.New("varint overflows a 64-bit integer") - } - if b < 0x80 { - return x | uint64(b)< 0 { - var err error - r, err = r.Index(path...) - if err != nil { - return nil, fmt.Errorf("failed to index nested byte stream reader: %w", err) - } - } - return wrpc.NewByteStreamReader(r), nil - case 1: - slog.Debug("reading ready byte stream contents") - buf, err := - func(r interface { - io.ByteReader - io.Reader - }) ([]byte, error) { - var x uint32 - var s uint - for i := 0; i < 5; i++ { - slog.Debug("reading byte list length", "i", i) - b, err := r.ReadByte() - if err != nil { - if i > 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return nil, fmt.Errorf("failed to read byte list length byte: %w", err) - } - if s == 28 && b > 0x0f { - return nil, errors.New("byte list length overflows a 32-bit integer") - } - if b < 0x80 { - x = x | uint32(b)< math.MaxUint32 { - return fmt.Errorf("outgoing pending stream chunk length of %d overflows a 32-bit integer", n) - } - if math.MaxUint32-uint32(n) < total { - return errors.New("total outgoing pending stream element count would overflow a 32-bit unsigned integer") - } - if n > 0 { - slog.Debug("writing pending stream chunk length", "len", n) - _, err = wrpc.WriteUint32(uint32(n), w) - if err != nil { - return fmt.Errorf("failed to write pending stream chunk length of %d: %w", n, err) - } - for _, v := range chunk { - slog.Debug("writing pending stream element", "i", total) - write, err := (func(wrpc.IndexWriter) error)(nil), func(v uint64, w io.Writer) (err error) { - b := make([]byte, binary.MaxVarintLen64) - i := binary.PutUvarint(b, uint64(v)) - slog.Debug("writing u64") - _, err = w.Write(b[:i]) - return err - }(v, w) - if err != nil { - return fmt.Errorf("failed to write pending stream chunk element %d: %w", total, err) - } - if write != nil { - wg.Add(1) - w, err := w.Index(total) - if err != nil { - return fmt.Errorf("failed to index nested stream writer: %w", err) - } - go func() { - defer wg.Done() - if err := write(w); err != nil { - wgErr.Store(err) - } - }() - } - total++ - } - } - if end { - if err := w.WriteByte(0); err != nil { - return fmt.Errorf("failed to write pending stream end byte: %w", err) - } - wg.Wait() - err := wgErr.Load() - if err == nil { - return nil - } - return err.(error) - } - } - }, nil - }(v.Numbers, w) + write0, err := wrpc.WriteStream(v.Numbers, w, func(v uint64, w wrpc.ByteWriter) (func(wrpc.IndexWriter) error, error) { + return (func(wrpc.IndexWriter) error)(nil), func(v uint64, w io.Writer) (err error) { + b := make([]byte, binary.MaxVarintLen64) + i := binary.PutUvarint(b, uint64(v)) + slog.Debug("writing u64") + _, err = w.Write(b[:i]) + return err + }(v, w) + }) if err != nil { return nil, fmt.Errorf("failed to write `numbers` field: %w", err) } @@ -119,59 +40,7 @@ func (v *Req) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) error, err writes[0] = write0 } slog.Debug("writing field", "name", "bytes") - write1, err := func(v io.ReadCloser, w interface { - io.ByteWriter - io.Writer - }) (write func(wrpc.IndexWriter) error, err error) { - slog.Debug("writing byte stream `stream::pending` status byte") - if err = w.WriteByte(0); err != nil { - return nil, fmt.Errorf("failed to write `stream::pending` byte: %w", err) - } - return func(w wrpc.IndexWriter) (err error) { - defer func() { - slog.Debug("closing byte list stream writer") - if cErr := v.Close(); cErr != nil { - if err == nil { - err = fmt.Errorf("failed to close pending byte stream: %w", cErr) - } else { - slog.Warn("failed to close pending byte stream", "err", cErr) - } - } - }() - chunk := make([]byte, wrpc.ReadChunkSize) - for { - var end bool - slog.Debug("reading pending byte stream contents") - n, err := v.Read(chunk) - if err == io.EOF { - end = true - slog.Debug("pending byte stream reached EOF") - } else if err != nil { - return fmt.Errorf("failed to read pending byte stream chunk: %w", err) - } - if n > math.MaxUint32 { - return fmt.Errorf("pending byte stream chunk length of %d overflows a 32-bit integer", n) - } - if n > 0 { - slog.Debug("writing pending byte stream chunk length", "len", n) - _, err = wrpc.WriteUint32(uint32(n), w) - if err != nil { - return fmt.Errorf("failed to write pending byte stream chunk length of %d: %w", n, err) - } - _, err = w.Write(chunk[:n]) - if err != nil { - return fmt.Errorf("failed to write pending byte stream chunk contents: %w", err) - } - } - if end { - if err := w.WriteByte(0); err != nil { - return fmt.Errorf("failed to write pending byte stream end byte: %w", err) - } - return nil - } - } - }, nil - }(v.Bytes, w) + write1, err := wrpc.WriteByteStream(v.Bytes, w) if err != nil { return nil, fmt.Errorf("failed to write `bytes` field: %w", err) } @@ -182,7 +51,7 @@ func (v *Req) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) error, err if len(writes) > 0 { return func(w wrpc.IndexWriter) error { var wg sync.WaitGroup - var wgErr atomic.Value + var wgErr atomic.Pointer[error] for index, write := range writes { wg.Add(1) w, err := w.Index(index) @@ -193,23 +62,22 @@ func (v *Req) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) error, err go func() { defer wg.Done() if err := write(w); err != nil { - wgErr.Store(err) + wgErr.Store(&err) } }() } wg.Wait() - err := wgErr.Load() - if err == nil { - return nil + if err := wgErr.Load(); err != nil { + return *err } - return err.(error) + return nil }, nil } return nil, nil } type Handler interface { - Echo(ctx__ context.Context, r *Req) (wrpc.Receiver[[]uint64], io.ReadCloser, error) + Echo(ctx__ context.Context, r *Req) (<-chan []uint64, <-chan []uint8, error) } func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { @@ -229,236 +97,58 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "failed to close writer", "instance", "wrpc-examples:streams/handler", "name", "echo", "err", err) } }() + ctx, cancel := context.WithCancel(ctx) + var wg sync.WaitGroup + defer func() { + go func() { + wg.Wait() + cancel() + }() + }() slog.DebugContext(ctx, "reading parameter", "i", 0) - p0, err := func(r wrpc.IndexReadCloser, path ...uint32) (*Req, error) { + p0, d0, err := func(r wrpc.IndexReadCloser, path ...uint32) (*Req, func(context.Context) error, error) { v := &Req{} var err error + var d func(context.Context) error + var ds []func(context.Context) error slog.Debug("reading field", "name", "numbers") - v.Numbers, err = func(r wrpc.IndexReadCloser, path ...uint32) (wrpc.Receiver[[]uint64], error) { - slog.Debug("reading stream status byte") - status, err := r.ReadByte() - if err != nil { - return nil, fmt.Errorf("failed to read stream status byte: %w", err) - } - switch status { - case 0: - if len(path) > 0 { - var err error - r, err = r.Index(path...) + v.Numbers, d, err = wrpc.ReadStream(r, func(r wrpc.IndexReadCloser, path ...uint32) (uint64, func(context.Context) error, error) { + v, err := func(r io.ByteReader) (uint64, error) { + var x uint64 + var s uint8 + for i := 0; i < 10; i++ { + slog.Debug("reading u64 byte", "i", i) + b, err := r.ReadByte() if err != nil { - return nil, fmt.Errorf("failed to index nested stream reader: %w", err) - } - } - var total uint32 - return wrpc.NewDecodeReceiver(r, func(r wrpc.IndexReadCloser) ([]uint64, error) { - slog.Debug("reading pending stream chunk length") - n, err := func(r io.ByteReader) (uint32, error) { - var x uint32 - var s uint8 - for i := 0; i < 5; i++ { - slog.Debug("reading u32 byte", "i", i) - b, err := r.ReadByte() - if err != nil { - if i > 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return x, fmt.Errorf("failed to read u32 byte: %w", err) - } - if s == 28 && b > 0x0f { - return x, errors.New("varint overflows a 32-bit integer") - } - if b < 0x80 { - return x | uint32(b)< 0 && err == io.EOF { + err = io.ErrUnexpectedEOF } - return x, errors.New("varint overflows a 32-bit integer") - }(r) - if err != nil { - return nil, fmt.Errorf("failed to read pending stream chunk length: %w", err) - } - if n == 0 { - return nil, io.EOF + return x, fmt.Errorf("failed to read u64 byte: %w", err) } - if math.MaxUint32-n < total { - return nil, errors.New("total incoming pending stream element count would overflow a 32-bit unsigned integer") + if s == 63 && b > 0x01 { + return x, errors.New("varint overflows a 64-bit integer") } - vs := wrpc.NewSlice[[]uint64](n) - for i := range n { - slog.Debug("reading pending stream element", "i", total) - v, err := func(r io.ByteReader) (uint64, error) { - var x uint64 - var s uint8 - for i := 0; i < 10; i++ { - slog.Debug("reading u64 byte", "i", i) - b, err := r.ReadByte() - if err != nil { - if i > 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return x, fmt.Errorf("failed to read u64 byte: %w", err) - } - if s == 63 && b > 0x01 { - return x, errors.New("varint overflows a 64-bit integer") - } - if b < 0x80 { - return x | uint64(b)< 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return nil, fmt.Errorf("failed to read list length byte: %w", err) - } - if s == 28 && b > 0x0f { - return nil, errors.New("list length overflows a 32-bit integer") - } - if b < 0x80 { - x = x | uint32(b)< 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return x, fmt.Errorf("failed to read u64 byte: %w", err) - } - if s == 63 && b > 0x01 { - return x, errors.New("varint overflows a 64-bit integer") - } - if b < 0x80 { - return x | uint64(b)< 0 { - var err error - r, err = r.Index(path...) - if err != nil { - return nil, fmt.Errorf("failed to index nested byte stream reader: %w", err) - } - } - return wrpc.NewByteStreamReader(r), nil - case 1: - slog.Debug("reading ready byte stream contents") - buf, err := - func(r interface { - io.ByteReader - io.Reader - }) ([]byte, error) { - var x uint32 - var s uint - for i := 0; i < 5; i++ { - slog.Debug("reading byte list length", "i", i) - b, err := r.ReadByte() - if err != nil { - if i > 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return nil, fmt.Errorf("failed to read byte list length byte: %w", err) - } - if s == 28 && b > 0x0f { - return nil, errors.New("byte list length overflows a 32-bit integer") - } - if b < 0x80 { - x = x | uint32(b)< math.MaxUint32 { - return fmt.Errorf("outgoing pending stream chunk length of %d overflows a 32-bit integer", n) - } - if math.MaxUint32-uint32(n) < total { - return errors.New("total outgoing pending stream element count would overflow a 32-bit unsigned integer") - } - if n > 0 { - slog.Debug("writing pending stream chunk length", "len", n) - _, err = wrpc.WriteUint32(uint32(n), w) - if err != nil { - return fmt.Errorf("failed to write pending stream chunk length of %d: %w", n, err) - } - for _, v := range chunk { - slog.Debug("writing pending stream element", "i", total) - write, err := (func(wrpc.IndexWriter) error)(nil), func(v uint64, w io.Writer) (err error) { - b := make([]byte, binary.MaxVarintLen64) - i := binary.PutUvarint(b, uint64(v)) - slog.Debug("writing u64") - _, err = w.Write(b[:i]) - return err - }(v, w) - if err != nil { - return fmt.Errorf("failed to write pending stream chunk element %d: %w", total, err) - } - if write != nil { - wg.Add(1) - w, err := w.Index(total) - if err != nil { - return fmt.Errorf("failed to index nested stream writer: %w", err) - } - go func() { - defer wg.Done() - if err := write(w); err != nil { - wgErr.Store(err) - } - }() - } - total++ - } - } - if end { - if err := w.WriteByte(0); err != nil { - return fmt.Errorf("failed to write pending stream end byte: %w", err) - } - wg.Wait() - err := wgErr.Load() - if err == nil { - return nil - } - return err.(error) - } - } - }, nil - }(r0, &buf) + write0, err := wrpc.WriteStream(r0, &buf, func(v uint64, w wrpc.ByteWriter) (func(wrpc.IndexWriter) error, error) { + return (func(wrpc.IndexWriter) error)(nil), func(v uint64, w io.Writer) (err error) { + b := make([]byte, binary.MaxVarintLen64) + i := binary.PutUvarint(b, uint64(v)) + slog.Debug("writing u64") + _, err = w.Write(b[:i]) + return err + }(v, w) + }) if err != nil { slog.WarnContext(ctx, "failed to write result value", "i", 0, "instance", "wrpc-examples:streams/handler", "name", "echo", "err", err) return @@ -575,59 +194,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { if write0 != nil { writes[0] = write0 } - write1, err := func(v io.ReadCloser, w interface { - io.ByteWriter - io.Writer - }) (write func(wrpc.IndexWriter) error, err error) { - slog.Debug("writing byte stream `stream::pending` status byte") - if err = w.WriteByte(0); err != nil { - return nil, fmt.Errorf("failed to write `stream::pending` byte: %w", err) - } - return func(w wrpc.IndexWriter) (err error) { - defer func() { - slog.Debug("closing byte list stream writer") - if cErr := v.Close(); cErr != nil { - if err == nil { - err = fmt.Errorf("failed to close pending byte stream: %w", cErr) - } else { - slog.Warn("failed to close pending byte stream", "err", cErr) - } - } - }() - chunk := make([]byte, wrpc.ReadChunkSize) - for { - var end bool - slog.Debug("reading pending byte stream contents") - n, err := v.Read(chunk) - if err == io.EOF { - end = true - slog.Debug("pending byte stream reached EOF") - } else if err != nil { - return fmt.Errorf("failed to read pending byte stream chunk: %w", err) - } - if n > math.MaxUint32 { - return fmt.Errorf("pending byte stream chunk length of %d overflows a 32-bit integer", n) - } - if n > 0 { - slog.Debug("writing pending byte stream chunk length", "len", n) - _, err = wrpc.WriteUint32(uint32(n), w) - if err != nil { - return fmt.Errorf("failed to write pending byte stream chunk length of %d: %w", n, err) - } - _, err = w.Write(chunk[:n]) - if err != nil { - return fmt.Errorf("failed to write pending byte stream chunk contents: %w", err) - } - } - if end { - if err := w.WriteByte(0); err != nil { - return fmt.Errorf("failed to write pending byte stream end byte: %w", err) - } - return nil - } - } - }, nil - }(r1, &buf) + write1, err := wrpc.WriteByteStream(r1, &buf) if err != nil { slog.WarnContext(ctx, "failed to write result value", "i", 1, "instance", "wrpc-examples:streams/handler", "name", "echo", "err", err) return @@ -652,7 +219,9 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { return } write := write + wg.Add(1) go func() { + defer wg.Done() if err := write(w); err != nil { slog.WarnContext(ctx, "failed to write nested result value", "instance", "wrpc-examples:streams/handler", "name", "echo", "err", err) } @@ -664,7 +233,9 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { return } write := write + wg.Add(1) go func() { + defer wg.Done() if err := write(w); err != nil { slog.WarnContext(ctx, "failed to write nested result value", "instance", "wrpc-examples:streams/handler", "name", "echo", "err", err) } diff --git a/examples/go/wasi-keyvalue-client/bindings/wasi/keyvalue/store/bindings.wrpc.go b/examples/go/wasi-keyvalue-client/bindings/wasi/keyvalue/store/bindings.wrpc.go index c69933f19..3cd91ae32 100644 --- a/examples/go/wasi-keyvalue-client/bindings/wasi/keyvalue/store/bindings.wrpc.go +++ b/examples/go/wasi-keyvalue-client/bindings/wasi/keyvalue/store/bindings.wrpc.go @@ -228,7 +228,7 @@ func (v *KeyResponse) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) er if len(writes) > 0 { return func(w wrpc.IndexWriter) error { var wg sync.WaitGroup - var wgErr atomic.Value + var wgErr atomic.Pointer[error] for index, write := range writes { wg.Add(1) w, err := w.Index(index) @@ -239,16 +239,15 @@ func (v *KeyResponse) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) er go func() { defer wg.Done() if err := write(w); err != nil { - wgErr.Store(err) + wgErr.Store(&err) } }() } wg.Wait() - err := wgErr.Load() - if err == nil { - return nil + if err := wgErr.Load(); err != nil { + return *err } - return err.(error) + return nil }, nil } return nil, nil @@ -312,7 +311,7 @@ func (v *KeyResponse) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) er if len(writes) > 0 { return func(w wrpc.IndexWriter) error { var wg sync.WaitGroup - var wgErr atomic.Value + var wgErr atomic.Pointer[error] for index, write := range writes { wg.Add(1) w, err := w.Index(index) @@ -323,16 +322,15 @@ func (v *KeyResponse) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) er go func() { defer wg.Done() if err := write(w); err != nil { - wgErr.Store(err) + wgErr.Store(&err) } }() } wg.Wait() - err := wgErr.Load() - if err == nil { - return nil + if err := wgErr.Load(); err != nil { + return *err } - return err.(error) + return nil }, nil } return nil, nil @@ -886,7 +884,7 @@ func Bucket_Set(ctx__ context.Context, wrpc__ wrpc.Invoker, self wrpc.Borrow[Buc if len(writes) > 0 { return func(w wrpc.IndexWriter) error { var wg sync.WaitGroup - var wgErr atomic.Value + var wgErr atomic.Pointer[error] for index, write := range writes { wg.Add(1) w, err := w.Index(index) @@ -897,16 +895,15 @@ func Bucket_Set(ctx__ context.Context, wrpc__ wrpc.Invoker, self wrpc.Borrow[Buc go func() { defer wg.Done() if err := write(w); err != nil { - wgErr.Store(err) + wgErr.Store(&err) } }() } wg.Wait() - err := wgErr.Load() - if err == nil { - return nil + if err := wgErr.Load(); err != nil { + return *err } - return err.(error) + return nil }, nil } return nil, nil diff --git a/examples/go/wasi-keyvalue-server/bindings/exports/wasi/keyvalue/store/bindings.wrpc.go b/examples/go/wasi-keyvalue-server/bindings/exports/wasi/keyvalue/store/bindings.wrpc.go index 75b45c930..c21689acc 100644 --- a/examples/go/wasi-keyvalue-server/bindings/exports/wasi/keyvalue/store/bindings.wrpc.go +++ b/examples/go/wasi-keyvalue-server/bindings/exports/wasi/keyvalue/store/bindings.wrpc.go @@ -228,7 +228,7 @@ func (v *KeyResponse) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) er if len(writes) > 0 { return func(w wrpc.IndexWriter) error { var wg sync.WaitGroup - var wgErr atomic.Value + var wgErr atomic.Pointer[error] for index, write := range writes { wg.Add(1) w, err := w.Index(index) @@ -239,16 +239,15 @@ func (v *KeyResponse) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) er go func() { defer wg.Done() if err := write(w); err != nil { - wgErr.Store(err) + wgErr.Store(&err) } }() } wg.Wait() - err := wgErr.Load() - if err == nil { - return nil + if err := wgErr.Load(); err != nil { + return *err } - return err.(error) + return nil }, nil } return nil, nil @@ -312,7 +311,7 @@ func (v *KeyResponse) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) er if len(writes) > 0 { return func(w wrpc.IndexWriter) error { var wg sync.WaitGroup - var wgErr atomic.Value + var wgErr atomic.Pointer[error] for index, write := range writes { wg.Add(1) w, err := w.Index(index) @@ -323,16 +322,15 @@ func (v *KeyResponse) WriteToIndex(w wrpc.ByteWriter) (func(wrpc.IndexWriter) er go func() { defer wg.Done() if err := write(w); err != nil { - wgErr.Store(err) + wgErr.Store(&err) } }() } wg.Wait() - err := wgErr.Load() - if err == nil { - return nil + if err := wgErr.Load(); err != nil { + return *err } - return err.(error) + return nil }, nil } return nil, nil @@ -756,7 +754,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { if len(writes) > 0 { return func(w wrpc.IndexWriter) error { var wg sync.WaitGroup - var wgErr atomic.Value + var wgErr atomic.Pointer[error] for index, write := range writes { wg.Add(1) w, err := w.Index(index) @@ -767,16 +765,15 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { go func() { defer wg.Done() if err := write(w); err != nil { - wgErr.Store(err) + wgErr.Store(&err) } }() } wg.Wait() - err := wgErr.Load() - if err == nil { - return nil + if err := wgErr.Load(); err != nil { + return *err } - return err.(error) + return nil }, nil } return nil, nil diff --git a/go/future.go b/go/future.go index 40d35f37c..3657092f2 100644 --- a/go/future.go +++ b/go/future.go @@ -1,6 +1,9 @@ package wrpc import ( + "bytes" + "context" + "errors" "fmt" "log/slog" ) @@ -23,27 +26,79 @@ func ReadFutureStatus(r ByteReader) (bool, error) { } } -// ReadFuture reads a future from `r` -func ReadFuture[T any](r IndexReadCloser, f func(IndexReadCloser) (T, error), path ...uint32) (Receiver[T], error) { +// ReadFuture reads a `future` from `r` using `f` to read its value. +// The returned channel receives the value once the future resolves and is closed afterwards. +// If the future is pending, the returned function reads it and must be called. If reading fails, +// the channel never receives a value and is never closed. +func ReadFuture[T any](r IndexReadCloser, f func(IndexReadCloser, ...uint32) (T, func(context.Context) error, error), path ...uint32) (<-chan T, func(context.Context) error, error) { slog.Debug("reading future status byte") ok, err := ReadFutureStatus(r) if err != nil { - return nil, err + return nil, nil, err } - if !ok { + ch := make(chan T, 1) + if ok { + slog.Debug("reading ready future") + v, deferred, err := f(r, path...) + if err != nil { + return nil, nil, fmt.Errorf("failed to read ready future: %w", err) + } + ch <- v + close(ch) + return ch, deferred, nil + } + if len(path) > 0 { + slog.Debug("indexing pending future reader") + r, err = r.Index(path...) + if err != nil { + return nil, nil, fmt.Errorf("failed to index nested future reader: %w", err) + } + } + return ch, func(ctx context.Context) error { if len(path) > 0 { - slog.Debug("indexing pending future reader") - r, err = r.Index(path...) - if err != nil { - return nil, fmt.Errorf("failed to index nested future reader: %w", err) - } + defer closeNested(r) } - return NewDecodeReceiver(r, f), nil + slog.Debug("reading pending future") + v, deferred, err := f(r) + if err != nil { + return fmt.Errorf("failed to read pending future: %w", err) + } + ch <- v + close(ch) + if deferred != nil { + return deferred(ctx) + } + return nil + }, nil +} + +// WriteFuture writes a `future` received from `v` to `w` using `f` to write its value +func WriteFuture[T any](v <-chan T, w ByteWriter, f func(T, ByteWriter) (func(IndexWriter) error, error)) (func(IndexWriter) error, error) { + if v == nil { + return nil, errors.New("outgoing future channel is nil") } - slog.Debug("reading ready future") - v, err := f(r) - if err != nil { - return nil, err + slog.Debug("writing `future::pending` status byte") + if err := w.WriteByte(0); err != nil { + return nil, fmt.Errorf("failed to write `future::pending` byte: %w", err) } - return NewCompleteReceiver(v), nil + return func(w IndexWriter) error { + slog.Debug("receiving outgoing pending future") + x, ok := <-v + if !ok { + return errors.New("outgoing pending future closed without a value") + } + slog.Debug("writing pending future value") + var buf bytes.Buffer + write, err := f(x, &buf) + if err != nil { + return fmt.Errorf("failed to encode pending future value: %w", err) + } + if _, err := w.Write(buf.Bytes()); err != nil { + return fmt.Errorf("failed to write pending future value: %w", err) + } + if write != nil { + return write(w) + } + return nil + }, nil } diff --git a/go/stream.go b/go/stream.go index a880cb950..c466376d2 100644 --- a/go/stream.go +++ b/go/stream.go @@ -1,113 +1,15 @@ package wrpc import ( - "bufio" "bytes" + "context" "errors" "fmt" - "io" "log/slog" "math" + "sync" ) -// ReadChunkSize is the size of chunks read from byte streams -const ReadChunkSize = 8192 - -type ByteStreamWriter struct { - r io.Reader - chunk []byte -} - -func (v *ByteStreamWriter) WriteTo(w ByteWriter) (err error) { - if len(v.chunk) == 0 { - v.chunk = make([]byte, ReadChunkSize) - } - buf := bufio.NewWriter(w) - defer func() { - if fErr := buf.Flush(); fErr != nil { - if err == nil { - err = fmt.Errorf("failed to flush pending byte stream writer: %w", fErr) - } else { - slog.Warn("failed to flush pending byte stream writer", "err", fErr) - } - } - }() - for { - var end bool - slog.Debug("reading pending byte stream contents") - n, err := v.r.Read(v.chunk) - if err == io.EOF { - end = true - slog.Debug("pending byte stream reached EOF") - } else if err != nil { - return fmt.Errorf("failed to read pending byte stream chunk: %w", err) - } - if n > math.MaxUint32 { - return fmt.Errorf("pending byte stream chunk length of %d overflows a 32-bit integer", n) - } - if n > 0 { - slog.Debug("writing pending byte stream chunk length", "len", n) - _, err = WriteUint32(uint32(n), buf) - if err != nil { - return fmt.Errorf("failed to write pending byte stream chunk length of %d: %w", n, err) - } - _, err = buf.Write(v.chunk[:n]) - if err != nil { - return fmt.Errorf("failed to write pending byte stream chunk contents: %w", err) - } - } - if end { - if err := buf.WriteByte(0); err != nil { - return fmt.Errorf("failed to write pending byte stream end byte: %w", err) - } - return nil - } - } -} - -type ByteStreamReader struct { - r ByteReadCloser - buf uint32 -} - -func (r *ByteStreamReader) Read(p []byte) (int, error) { - n := r.buf - if n == 0 { - slog.Debug("reading pending byte stream chunk length") - var err error - n, err = ReadUint32(r.r) - if err != nil { - return 0, fmt.Errorf("failed to read pending byte stream chunk length: %w", err) - } - if n == 0 { - return 0, io.EOF - } - } - if len(p) > int(n) { - p = p[:n] - } - slog.Debug("reading pending byte stream chunk contents", "len", n) - rn, err := r.r.Read(p) - if err != nil { - return rn, fmt.Errorf("failed to read pending stream chunk bytes: %w", err) - } - if rn > int(n) { - return rn, errors.New("read more bytes than requested") - } - r.buf = n - uint32(rn) - return rn, nil -} - -func (r *ByteStreamReader) Close() error { - return r.r.Close() -} - -func NewByteStreamReader(r ByteReadCloser) *ByteStreamReader { - return &ByteStreamReader{ - r: r, - } -} - // ReadStreamStatus reads a single byte from `r` and returns: // - `true` if stream is "ready" // - `false` if stream is "pending" @@ -126,92 +28,272 @@ func ReadStreamStatus(r ByteReader) (bool, error) { } } -// ReadByteStream reads a stream of bytes from `r` -func ReadByteStream(r IndexReader, path ...uint32) (io.ReadCloser, error) { +// ReadByteStream reads a `stream` from `r`. +// The returned channel receives the chunks of the stream and is closed once the stream ends. +// If the stream is pending, the returned function reads it and must be called. It closes the channel +// if reading fails or `ctx` is done. +func ReadByteStream(r IndexReadCloser, path ...uint32) (<-chan []byte, func(context.Context) error, error) { slog.Debug("reading byte stream status byte") ok, err := ReadStreamStatus(r) if err != nil { - return nil, err + return nil, nil, err } - if !ok { - r, err := r.Index(path...) + if ok { + slog.Debug("reading ready byte stream") + buf, err := ReadByteList(r) if err != nil { - return nil, fmt.Errorf("failed to index reader: %w", err) + return nil, nil, fmt.Errorf("failed to read ready byte stream: %w", err) + } + ch := make(chan []byte, 1) + if len(buf) > 0 { + ch <- buf } - return NewByteStreamReader(r), nil + close(ch) + return ch, nil, nil } - slog.Debug("reading ready byte stream") - buf, err := ReadByteList(r) - if err != nil { - return nil, fmt.Errorf("failed to read bytes: %w", err) + if len(path) > 0 { + slog.Debug("indexing pending byte stream reader") + r, err = r.Index(path...) + if err != nil { + return nil, nil, fmt.Errorf("failed to index nested byte stream reader: %w", err) + } } - slog.Debug("read ready byte stream", "len", len(buf)) - return io.NopCloser(bytes.NewReader(buf)), nil + ch := make(chan []byte) + return ch, func(ctx context.Context) error { + if len(path) > 0 { + defer closeNested(r) + } + defer close(ch) + for { + slog.Debug("reading pending byte stream chunk") + buf, err := ReadByteList(r) + if err != nil { + return fmt.Errorf("failed to read pending byte stream chunk: %w", err) + } + if len(buf) == 0 { + slog.Debug("pending byte stream ended") + return nil + } + select { + case ch <- buf: + case <-ctx.Done(): + return fmt.Errorf("failed to send pending byte stream chunk: %w", context.Cause(ctx)) + } + } + }, nil } -// ReadStream reads a stream from `r` -func ReadStream[T any](r IndexReader, f func(IndexReader) (T, error), path ...uint32) (Receiver[[]T], error) { +// ReadStream reads a `stream` from `r` using `f` to read its elements. +// The returned channel receives the chunks of the stream and is closed once the stream ends. +// If the stream is pending, the returned function reads it and must be called. It closes the channel +// if reading fails or `ctx` is done. +func ReadStream[T any](r IndexReadCloser, f func(IndexReadCloser, ...uint32) (T, func(context.Context) error, error), path ...uint32) (<-chan []T, func(context.Context) error, error) { slog.Debug("reading stream status byte") ok, err := ReadStreamStatus(r) if err != nil { - return nil, err + return nil, nil, err } - if !ok { - r, err := r.Index(path...) + if ok { + slog.Debug("reading ready stream length") + n, err := ReadUint32(r) if err != nil { - return nil, fmt.Errorf("failed to index reader: %w", err) + return nil, nil, fmt.Errorf("failed to read ready stream length: %w", err) } - return NewDecodeReceiver(r, func(r IndexReadCloser) ([]T, error) { - slog.Debug("reading pending stream chunk length") - n, err := ReadUint32(r) + vs := NewSlice[[]T](n) + var ds []func(context.Context) error + for i := range n { + v, deferred, err := f(r, append(path, i)...) if err != nil { - return nil, fmt.Errorf("failed to read pending stream chunk length: %w", err) - } - if n == 0 { - slog.Debug("pending stream EOF chunk received") - return nil, io.EOF + return nil, nil, fmt.Errorf("failed to read ready stream element %d: %w", i, err) } - vs := NewSlice[[]T](n) - for i := range n { - v, err := f(r) + vs = append(vs, v) + ds = append(ds, deferred) + } + ch := make(chan []T, 1) + if len(vs) > 0 { + ch <- vs + } + close(ch) + return ch, JoinDeferred(ds...), nil + } + if len(path) > 0 { + slog.Debug("indexing pending stream reader") + r, err = r.Index(path...) + if err != nil { + return nil, nil, fmt.Errorf("failed to index nested stream reader: %w", err) + } + } + ch := make(chan []T) + return ch, func(ctx context.Context) error { + if len(path) > 0 { + defer closeNested(r) + } + var wg sync.WaitGroup + var errMu sync.Mutex + var errs []error + err := func() error { + defer close(ch) + var total uint32 + for { + slog.Debug("reading pending stream chunk length") + n, err := ReadUint32(r) if err != nil { - return nil, fmt.Errorf("failed to read pending stream chunk element %d: %w", i, err) + return fmt.Errorf("failed to read pending stream chunk length: %w", err) + } + if n == 0 { + slog.Debug("pending stream ended") + return nil + } + if math.MaxUint32-n < total { + return errors.New("total pending stream element count would overflow a 32-bit unsigned integer") + } + vs := NewSlice[[]T](n) + for range n { + v, deferred, err := f(r, total) + if err != nil { + return fmt.Errorf("failed to read pending stream element %d: %w", total, err) + } + if deferred != nil { + wg.Add(1) + go func() { + defer wg.Done() + if err := deferred(ctx); err != nil { + errMu.Lock() + errs = append(errs, err) + errMu.Unlock() + } + }() + } + vs = append(vs, v) + total++ + } + select { + case ch <- vs: + case <-ctx.Done(): + return fmt.Errorf("failed to send pending stream chunk: %w", context.Cause(ctx)) } - vs = append(vs, v) } - return vs, nil - }), nil + }() + wg.Wait() + return errors.Join(append(errs, err)...) + }, nil +} + +// WriteByteStream writes a `stream` with chunks received from `v` to `w` +func WriteByteStream(v <-chan []byte, w ByteWriter) (func(IndexWriter) error, error) { + if v == nil { + return nil, errors.New("outgoing byte stream channel is nil") } - slog.Debug("reading ready stream") - vs, err := ReadList(r, f) - if err != nil { - return nil, fmt.Errorf("failed to read ready stream: %w", err) + slog.Debug("writing `stream::pending` status byte") + if err := w.WriteByte(0); err != nil { + return nil, fmt.Errorf("failed to write `stream::pending` byte: %w", err) } - slog.Debug("read ready stream", "len", len(vs)) - return NewCompleteReceiver(vs), nil + return func(w IndexWriter) error { + var buf bytes.Buffer + for chunk := range v { + if len(chunk) == 0 { + continue + } + slog.Debug("writing pending byte stream chunk", "len", len(chunk)) + buf.Reset() + if _, err := WriteByteList(chunk, &buf); err != nil { + go drain(v) + return fmt.Errorf("failed to encode pending byte stream chunk: %w", err) + } + if _, err := w.Write(buf.Bytes()); err != nil { + go drain(v) + return fmt.Errorf("failed to write pending byte stream chunk: %w", err) + } + } + slog.Debug("writing pending byte stream end") + if err := w.WriteByte(0); err != nil { + return fmt.Errorf("failed to write pending byte stream end byte: %w", err) + } + return nil + }, nil } -func WriteByteStream(r io.Reader, w IndexWriter, chunk []byte, path ...uint32) (err error) { - slog.Debug("writing byte stream `stream::pending` status byte") - if err := w.WriteByte(0); err != nil { - return fmt.Errorf("failed to write `stream::pending` byte: %w", err) +// WriteStream writes a `stream` with chunks received from `v` to `w` using `f` to write its elements +func WriteStream[T any](v <-chan []T, w ByteWriter, f func(T, ByteWriter) (func(IndexWriter) error, error)) (func(IndexWriter) error, error) { + if v == nil { + return nil, errors.New("outgoing stream channel is nil") } - wi, err := w.Index(path...) - if err != nil { - return fmt.Errorf("failed to index reader: %w", err) + slog.Debug("writing `stream::pending` status byte") + if err := w.WriteByte(0); err != nil { + return nil, fmt.Errorf("failed to write `stream::pending` byte: %w", err) } - s := &ByteStreamWriter{r, chunk} - defer func() { - if cErr := wi.Close(); cErr != nil { - if err == nil { - err = fmt.Errorf("failed to close pending byte stream: %w", cErr) - } else { - slog.Warn("failed to close pending byte stream", "err", cErr) + return func(w IndexWriter) error { + var wg sync.WaitGroup + var errMu sync.Mutex + var errs []error + err := func() error { + var total uint32 + var buf bytes.Buffer + writes := make(map[uint32]func(IndexWriter) error) + for chunk := range v { + n := len(chunk) + if n == 0 { + continue + } + if n > math.MaxUint32 || math.MaxUint32-uint32(n) < total { + return errors.New("total pending stream element count would overflow a 32-bit unsigned integer") + } + slog.Debug("writing pending stream chunk", "len", n) + buf.Reset() + if _, err := WriteUint32(uint32(n), &buf); err != nil { + return fmt.Errorf("failed to encode pending stream chunk length of %d: %w", n, err) + } + clear(writes) + for _, v := range chunk { + write, err := f(v, &buf) + if err != nil { + return fmt.Errorf("failed to encode pending stream element %d: %w", total, err) + } + if write != nil { + writes[total] = write + } + total++ + } + if _, err := w.Write(buf.Bytes()); err != nil { + return fmt.Errorf("failed to write pending stream chunk: %w", err) + } + for i, write := range writes { + w, err := w.Index(i) + if err != nil { + return fmt.Errorf("failed to index nested stream writer: %w", err) + } + wg.Add(1) + go func() { + defer wg.Done() + if err := write(w); err != nil { + errMu.Lock() + errs = append(errs, err) + errMu.Unlock() + } + }() + } + } + slog.Debug("writing pending stream end") + if err := w.WriteByte(0); err != nil { + return fmt.Errorf("failed to write pending stream end byte: %w", err) } + return nil + }() + if err != nil { + go drain(v) } - }() - if err := s.WriteTo(wi); err != nil { - return fmt.Errorf("failed to write stream contents: %w", err) + wg.Wait() + return errors.Join(append(errs, err)...) + }, nil +} + +func closeNested(r IndexReadCloser) { + if err := r.Close(); err != nil { + slog.Debug("failed to close nested reader", "err", err) + } +} + +func drain[T any](v <-chan T) { + for range v { } - return nil } diff --git a/go/wrpc.go b/go/wrpc.go index 56bf67fb7..fcd11a988 100644 --- a/go/wrpc.go +++ b/go/wrpc.go @@ -3,7 +3,10 @@ package wrpc import ( "bufio" "context" + "errors" "io" + "slices" + "sync" ) // Invoke is the client-side transport handle @@ -110,9 +113,29 @@ type ByteReader interface { io.Reader } -type Receiver[T any] interface { - Receive() (T, error) - io.Closer +// JoinDeferred returns a function, which calls all non-nil `fs` concurrently and returns their joined errors. +// It returns `nil` if all `fs` are `nil`. +func JoinDeferred(fs ...func(context.Context) error) func(context.Context) error { + fs = slices.DeleteFunc(fs, func(f func(context.Context) error) bool { return f == nil }) + switch len(fs) { + case 0: + return nil + case 1: + return fs[0] + } + return func(ctx context.Context) error { + errs := make([]error, len(fs)) + var wg sync.WaitGroup + for i, f := range fs { + wg.Add(1) + go func() { + defer wg.Done() + errs[i] = f(ctx) + }() + } + wg.Wait() + return errors.Join(errs...) + } } type ByteReadCloser interface { @@ -120,65 +143,6 @@ type ByteReadCloser interface { io.Closer } -type CompleteReceiver[T any] struct { - v T - ready bool -} - -func (r *CompleteReceiver[T]) Receive() (T, error) { - defer func() { - r.ready = false - }() - if !r.ready { - return CompleteReceiver[T]{}.v, io.EOF - } - return r.v, nil -} - -func (r *CompleteReceiver[T]) Close() error { - if c, ok := (any)(r.v).(io.Closer); ok { - return c.Close() - } - return nil -} - -func NewCompleteReceiver[T any](v T) *CompleteReceiver[T] { - return &CompleteReceiver[T]{v, true} -} - -type DecodeReceiver[T any] struct { - r IndexReadCloser - decode func(IndexReadCloser) (T, error) -} - -func (r *DecodeReceiver[T]) Receive() (T, error) { - return r.decode(r.r) -} - -func (r *DecodeReceiver[T]) Close() error { - return r.r.Close() -} - -func NewDecodeReceiver[T any](r IndexReadCloser, decode func(IndexReadCloser) (T, error)) *DecodeReceiver[T] { - return &DecodeReceiver[T]{r, decode} -} - -type nestedReceiver[T any, A Receiver[B], B Receiver[T]] struct { - rx A -} - -func (r nestedReceiver[T, A, B]) Receive() (Receiver[T], error) { - return r.rx.Receive() -} - -func (r nestedReceiver[T, A, B]) Close() error { - return r.rx.Close() -} - -func NewNestedReceiver[T any, A Receiver[B], B Receiver[T]](rx A) Receiver[Receiver[T]] { - return nestedReceiver[T, A, B]{rx} -} - type BufReadCloser struct { *bufio.Reader io.Closer diff --git a/tests/go/async.go b/tests/go/async.go index 46a494afc..a4801dcee 100644 --- a/tests/go/async.go +++ b/tests/go/async.go @@ -3,34 +3,30 @@ package integration import ( - "bytes" "context" "errors" - "io" "log/slog" - wrpc "wrpc.io/go" "wrpc.io/tests/go/bindings/async_server/exports/wrpc_test/integration/async" + "wrpc.io/tests/go/internal" ) type AsyncHandler struct{} -func (AsyncHandler) WithStreams(ctx context.Context) (io.ReadCloser, wrpc.Receiver[[][]string], error) { +func (AsyncHandler) WithStreams(ctx context.Context) (<-chan []byte, <-chan [][]string, error) { slog.DebugContext(ctx, "handling `with-streams`") - buf := io.NopCloser(bytes.NewBuffer([]byte("test"))) - str := wrpc.NewCompleteReceiver([][]string{{"foo"}, {"bar", "baz"}}) - return buf, str, nil + return internal.Ready([]byte("test")), internal.Ready([][]string{{"foo"}, {"bar", "baz"}}), nil } -func (AsyncHandler) WithFuture(ctx context.Context, x *async.Something, s io.ReadCloser) (wrpc.Receiver[io.ReadCloser], error) { +func (AsyncHandler) WithFuture(ctx context.Context, x *async.Something, s <-chan []byte) (<-chan <-chan []byte, error) { slog.DebugContext(ctx, "handling `with-future`", "x", x) if x.Foo != "bar" { return nil, errors.New("`foo` is not `bar`") } - return wrpc.NewCompleteReceiver(s), nil + return internal.Ready(s), nil } -func (AsyncHandler) IdentityNestedAsync(ctx context.Context, v wrpc.Receiver[wrpc.Receiver[wrpc.Receiver[wrpc.Receiver[[]string]]]]) (wrpc.Receiver[wrpc.Receiver[wrpc.Receiver[wrpc.Receiver[[]string]]]], error) { +func (AsyncHandler) IdentityNestedAsync(ctx context.Context, v <-chan <-chan <-chan <-chan []string) (<-chan <-chan <-chan <-chan []string, error) { slog.DebugContext(ctx, "handling `identity-nested-async`") return v, nil } diff --git a/tests/go/async_test.go b/tests/go/async_test.go index 2894755bc..aa3a21d45 100644 --- a/tests/go/async_test.go +++ b/tests/go/async_test.go @@ -4,38 +4,18 @@ package integration_test import ( "context" - "io" "log/slog" - "reflect" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - wrpc "wrpc.io/go" integration "wrpc.io/tests/go" "wrpc.io/tests/go/bindings/async_client/wrpc_test/integration/async" "wrpc.io/tests/go/bindings/async_server" "wrpc.io/tests/go/internal" ) -type chunkReceiver[T any] struct { - chunks [][]T -} - -func (r *chunkReceiver[T]) Receive() ([]T, error) { - if len(r.chunks) == 0 { - return nil, io.EOF - } - var v []T - v, r.chunks = r.chunks[0], r.chunks[1:] - return v, nil -} - -func (r *chunkReceiver[T]) Close() error { - return nil -} - func TestAsync(t *testing.T) { var cancel func() ctx := context.Background() @@ -58,87 +38,67 @@ func TestAsync(t *testing.T) { t.Run("with-streams", func(t *testing.T) { slog.DebugContext(ctx, "calling `wrpc-test:integration/async.with-streams`") - byteRx, stringListRx, err := async.WithStreams(ctx, client) - if err != nil { - t.Errorf("failed to call `wrpc-test:integration/async.with-streams`: %s", err) - return - } - b, err := io.ReadAll(byteRx) - if err != nil { - t.Errorf("failed to read from stream: %s", err) - return + byteRx, stringListRx, errCh, err := async.WithStreams(ctx, client) + require.NoError(t, err, "failed to call `wrpc-test:integration/async.with-streams`") + var b []byte + for chunk := range byteRx { + b = append(b, chunk...) } - if string(b) != "test" { - t.Errorf("expected: `test`, got: %s", string(b)) - return + assert.Equal(t, "test", string(b)) + var ss [][]string + for chunk := range stringListRx { + ss = append(ss, chunk...) } - if err := byteRx.Close(); err != nil { - t.Errorf("failed to close byte reader: %s", err) - return + assert.Equal(t, [][]string{{"foo"}, {"bar", "baz"}}, ss) + for err := range errCh { + require.NoError(t, err, "failed to receive async value from peer") } + }) - ss, err := stringListRx.Receive() - if err != nil { - t.Errorf("failed to receive ready list stream: %s", err) - return - } - expected := [][]string{{"foo"}, {"bar", "baz"}} - if !reflect.DeepEqual(ss, expected) { - t.Errorf("expected: `%#v`, got: %#v", expected, ss) - return + t.Run("with-streams-cancel", func(t *testing.T) { + callCtx, callCancel := context.WithCancel(ctx) + _, stringListRx, errCh, err := async.WithStreams(callCtx, client) + require.NoError(t, err, "failed to call `wrpc-test:integration/async.with-streams`") + for range stringListRx { } - ss, err = stringListRx.Receive() - if ss != nil || err != io.EOF { - t.Errorf("ready list should have returned (nil, io.EOF), got: (%#v, %v)", ss, err) - return - } - if err := stringListRx.Close(); err != nil { - t.Errorf("failed to close string list receiver: %s", err) - return + callCancel() + timeout := time.After(5 * time.Second) + for { + select { + case _, ok := <-errCh: + if !ok { + return + } + case <-timeout: + t.Fatal("error channel not closed after cancellation") + } } }) t.Run("identity-nested-async", func(t *testing.T) { slog.DebugContext(ctx, "calling `wrpc-test:integration/async.identity-nested-async`") + stream := make(chan []string, 3) + stream <- []string{"foo"} + stream <- []string{} + stream <- []string{"bar", "baz"} + close(stream) fut0, errCh, err := async.IdentityNestedAsync(ctx, client, - wrpc.NewNestedReceiver(wrpc.NewCompleteReceiver( - wrpc.NewCompleteReceiver( - wrpc.NewNestedReceiver(wrpc.NewCompleteReceiver( - &chunkReceiver[string]{[][]string{{"foo"}, {}, {"bar", "baz"}}}, - )), - ), - )), + internal.Ready(internal.Ready(internal.Ready((<-chan []string)(stream)))), ) - if err != nil { - t.Errorf("failed to call `wrpc-test:integration/async.identity-nested-async`: %s", err) - return - } - for err := range errCh { - t.Errorf("failed to send async value to peer: %s", err) - return - } - fut1, err := fut0.Receive() - require.NoError(t, err, "failed to receive future 0") - fut2, err := fut1.Receive() - require.NoError(t, err, "failed to receive future 1") - - stream, err := fut2.Receive() - require.NoError(t, err, "failed to receive future 2") - + require.NoError(t, err, "failed to call `wrpc-test:integration/async.identity-nested-async`") + fut1, ok := <-fut0 + require.True(t, ok, "failed to receive future 0") + fut2, ok := <-fut1 + require.True(t, ok, "failed to receive future 1") + rx, ok := <-fut2 + require.True(t, ok, "failed to receive future 2") var ss []string - for err != io.EOF { - var v []string - v, err = stream.Receive() - if err != io.EOF { - require.NoError(t, err, "failed to receive stream element") - } - ss = append(ss, v...) + for chunk := range rx { + ss = append(ss, chunk...) } - assert.Equal(t, ss, []string{"foo", "bar", "baz"}) - - if err := fut0.Close(); err != nil { - t.Errorf("failed to close async value receiver: %s", err) - return + assert.Equal(t, []string{"foo", "bar", "baz"}, ss) + for err := range errCh { + require.NoError(t, err, "failed to exchange async values with peer") } }) diff --git a/tests/go/internal/internal.go b/tests/go/internal/internal.go index 957fa86b5..d01250941 100644 --- a/tests/go/internal/internal.go +++ b/tests/go/internal/internal.go @@ -64,3 +64,11 @@ func Accept(ctx context.Context, srv *wrpctcp.Server) { } }() } + +// Ready returns a closed channel, which contains `v` +func Ready[T any](v T) <-chan T { + ch := make(chan T, 1) + ch <- v + close(ch) + return ch +} From 714a5d16583cf4c7b9e5cd00da140293e0d48582 Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Mon, 26 Oct 2026 19:22:11 +0100 Subject: [PATCH 02/10] fix(wit-bindgen-go): log the reader close error The served function logged the handler error instead of the error returned by closing the parameter reader. Assisted-by: claude:claude-opus-5-5 --- crates/wit-bindgen-go/src/interface.rs | 2 +- .../wrpc_examples/hello/handler/bindings.wrpc.go | 2 +- .../resources/resources/bindings.wrpc.go | 8 ++++---- .../wrpc_examples/streams/handler/bindings.wrpc.go | 2 +- .../exports/wasi/keyvalue/store/bindings.wrpc.go | 12 ++++++------ 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/wit-bindgen-go/src/interface.rs b/crates/wit-bindgen-go/src/interface.rs index a3b027f8b..1782e76b4 100644 --- a/crates/wit-bindgen-go/src/interface.rs +++ b/crates/wit-bindgen-go/src/interface.rs @@ -2277,7 +2277,7 @@ func ServeInterface(s {wrpc}.Server, h Handler) (stop func() error, err error) { self.src, r#") if cErr := r.Close(); cErr != nil {{ - {slog}.ErrorContext(ctx, "failed to close reader", "instance", "{instance}", "name", "{name}", "err", err) + {slog}.ErrorContext(ctx, "failed to close reader", "instance", "{instance}", "name", "{name}", "err", cErr) }} if err != nil {{ {slog}.WarnContext(ctx, "failed to handle invocation", "instance", "{instance}", "name", "{name}", "err", err) diff --git a/examples/go/hello-server/bindings/exports/wrpc_examples/hello/handler/bindings.wrpc.go b/examples/go/hello-server/bindings/exports/wrpc_examples/hello/handler/bindings.wrpc.go index 91daab8cb..925871ddb 100644 --- a/examples/go/hello-server/bindings/exports/wrpc_examples/hello/handler/bindings.wrpc.go +++ b/examples/go/hello-server/bindings/exports/wrpc_examples/hello/handler/bindings.wrpc.go @@ -36,7 +36,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wrpc-examples:hello/handler.hello` handler") r0, err := h.Hello(ctx) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:hello/handler", "name", "hello", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:hello/handler", "name", "hello", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wrpc-examples:hello/handler", "name", "hello", "err", err) diff --git a/examples/go/resources-server/bindings/exports/wrpc_examples/resources/resources/bindings.wrpc.go b/examples/go/resources-server/bindings/exports/wrpc_examples/resources/resources/bindings.wrpc.go index 9bc0d36a9..0d7441e36 100644 --- a/examples/go/resources-server/bindings/exports/wrpc_examples/resources/resources/bindings.wrpc.go +++ b/examples/go/resources-server/bindings/exports/wrpc_examples/resources/resources/bindings.wrpc.go @@ -41,7 +41,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wrpc-examples:resources/resources.foo` handler") r0, err := h.Foo(ctx) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:resources/resources", "name", "foo", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:resources/resources", "name", "foo", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wrpc-examples:resources/resources", "name", "foo", "err", err) @@ -160,7 +160,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wrpc-examples:resources/resources.foo.baz` handler") r0, err := h.Foo_Baz(ctx, p0) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:resources/resources", "name", "foo.baz", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:resources/resources", "name", "foo.baz", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wrpc-examples:resources/resources", "name", "foo.baz", "err", err) @@ -279,7 +279,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wrpc-examples:resources/resources.foo.bar` handler") r0, err := h.Foo_Bar(ctx, p0) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:resources/resources", "name", "foo.bar", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:resources/resources", "name", "foo.bar", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wrpc-examples:resources/resources", "name", "foo.bar", "err", err) @@ -398,7 +398,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wrpc-examples:resources/resources.bar` handler") r0, err := h.Bar(ctx, p0) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:resources/resources", "name", "bar", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:resources/resources", "name", "bar", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wrpc-examples:resources/resources", "name", "bar", "err", err) diff --git a/examples/go/streams-server/bindings/exports/wrpc_examples/streams/handler/bindings.wrpc.go b/examples/go/streams-server/bindings/exports/wrpc_examples/streams/handler/bindings.wrpc.go index 33dc2629e..3dd56a01d 100644 --- a/examples/go/streams-server/bindings/exports/wrpc_examples/streams/handler/bindings.wrpc.go +++ b/examples/go/streams-server/bindings/exports/wrpc_examples/streams/handler/bindings.wrpc.go @@ -168,7 +168,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wrpc-examples:streams/handler.echo` handler") r0, r1, err := h.Echo(ctx, p0) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:streams/handler", "name", "echo", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wrpc-examples:streams/handler", "name", "echo", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wrpc-examples:streams/handler", "name", "echo", "err", err) diff --git a/examples/go/wasi-keyvalue-server/bindings/exports/wasi/keyvalue/store/bindings.wrpc.go b/examples/go/wasi-keyvalue-server/bindings/exports/wasi/keyvalue/store/bindings.wrpc.go index c21689acc..e67f97691 100644 --- a/examples/go/wasi-keyvalue-server/bindings/exports/wasi/keyvalue/store/bindings.wrpc.go +++ b/examples/go/wasi-keyvalue-server/bindings/exports/wasi/keyvalue/store/bindings.wrpc.go @@ -473,7 +473,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wasi:keyvalue/store@0.2.0-draft2.open` handler") r0, err := h.Open(ctx, p0) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "open", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "open", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "open", "err", err) @@ -678,7 +678,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wasi:keyvalue/store@0.2.0-draft2.bucket.get` handler") r0, err := h.Bucket_Get(ctx, p0, p1) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.get", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.get", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.get", "err", err) @@ -985,7 +985,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wasi:keyvalue/store@0.2.0-draft2.bucket.set` handler") r0, err := h.Bucket_Set(ctx, p0, p1, p2) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.set", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.set", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.set", "err", err) @@ -1162,7 +1162,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wasi:keyvalue/store@0.2.0-draft2.bucket.delete` handler") r0, err := h.Bucket_Delete(ctx, p0, p1) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.delete", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.delete", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.delete", "err", err) @@ -1339,7 +1339,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wasi:keyvalue/store@0.2.0-draft2.bucket.exists` handler") r0, err := h.Bucket_Exists(ctx, p0, p1) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.exists", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.exists", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.exists", "err", err) @@ -1550,7 +1550,7 @@ func ServeInterface(s wrpc.Server, h Handler) (stop func() error, err error) { slog.DebugContext(ctx, "calling `wasi:keyvalue/store@0.2.0-draft2.bucket.list-keys` handler") r0, err := h.Bucket_ListKeys(ctx, p0, p1) if cErr := r.Close(); cErr != nil { - slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.list-keys", "err", err) + slog.ErrorContext(ctx, "failed to close reader", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.list-keys", "err", cErr) } if err != nil { slog.WarnContext(ctx, "failed to handle invocation", "instance", "wasi:keyvalue/store@0.2.0-draft2", "name", "bucket.list-keys", "err", err) From af65d71a64a42909ca988d44804376e99e21cc5b Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Fri, 23 Oct 2026 19:33:59 +0200 Subject: [PATCH 03/10] docs(spec): close paths with empty frames An empty frame closes its path, after which no more frames may be sent on it, and the end of transmit closes every path. A pending `future` without a payload type has an empty value encoding, so its resolution is signalled by closing its path, either with an empty frame or by ending transmit. Assisted-by: claude:claude-opus-5-5 --- SPEC.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 92c01dba5..1df1fe66d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -79,7 +79,9 @@ record frame { } ``` -It is assumed that streams using this framing protocol can communicate "closing" to peers using some out-of-band mechanism. +An empty frame, i.e. a frame with empty `data`, closes its `path`: the sender MUST NOT send any more frames on that `path`, including another empty frame, and a receiver MUST treat a frame received on a closed `path` as an error. Closing a `path` does not close the paths nested in it. A sender MAY leave a `path` open, unless stated otherwise (see [Futures](#futures)). + +Frames do not communicate the end of transmit, i.e. that no more frames follow. Each transport MUST communicate it using its own mechanism, for example, TCP shutdown or finishing a QUIC stream. The end of transmit closes every `path`. ## Transport specifications @@ -106,6 +108,8 @@ For example: If `v` is pending, encoded `bool` value is sent on index `0` (corresponding to first parameter) +A `future` without a payload type has an empty value encoding, so once such a pending future resolves, the sender MUST close its index, either with an empty frame or by ending transmit (see [Framed stream specification](#framed-stream-specification)), and the receiver MUST consider it resolved once the index is closed. + ### Streams `stream` values are encoded as `variant stream { pending, ready(list) }`. From c302c8bdd0be2ef18d934d996941ee6afc763af6 Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Mon, 26 Oct 2026 18:18:08 +0100 Subject: [PATCH 04/10] fix(go): do not write empty frames An empty frame closes its path, so writing an empty buffer, for example an empty `string` or `list` value, made Rust and JS peers fail on the frames that follow on the same path. Assisted-by: claude:claude-opus-5-5 --- go/frame_writer.go | 3 +++ go/frame_writer_test.go | 1 + 2 files changed, 4 insertions(+) diff --git a/go/frame_writer.go b/go/frame_writer.go index 674599946..f77b799af 100644 --- a/go/frame_writer.go +++ b/go/frame_writer.go @@ -111,6 +111,9 @@ func sendFrame(ctx context.Context, ch chan<- Frame, frame Frame) error { } func (w *FrameStreamWriter) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } data := make([]byte, len(p)) copy(data, p) if err := sendFrame(w.ctx, w.ch, Frame{ diff --git a/go/frame_writer_test.go b/go/frame_writer_test.go index accf14284..94637cf49 100644 --- a/go/frame_writer_test.go +++ b/go/frame_writer_test.go @@ -71,6 +71,7 @@ func TestFrameStreamWriter(t *testing.T) { var dst sink w := wrpc.NewFrameStreamWriter(ctx, &dst) + assertWriteString(t, w, "") assertWriteString(t, w, "hello") assertClose(t, w) From 4e25010fb0a56677e69fe998077840a1e89522cd Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Fri, 23 Oct 2026 19:33:59 +0200 Subject: [PATCH 05/10] fix(transport): close paths with empty frames Ingress closes the subscription of a path on an empty frame and fails on any further frame for it. Readers of a path closed by an empty frame end cleanly, even if ingress fails afterwards. `Outgoing::close` sends an empty frame, which `write_future` uses to signal the resolution of a value with an empty encoding, such as that of a `future` without a payload type, before transmitting its deferred values. A pending future is resolved only once no data follows its value, so a `future` without a payload type is resolved once its path is closed or the transmit ends. Invocations with no parameters send just the header. Assisted-by: claude:claude-opus-5-5 --- crates/transport/src/frame/conn/client.rs | 8 +- crates/transport/src/frame/conn/mod.rs | 193 +++++++++++++++------- crates/transport/src/value.rs | 26 ++- 3 files changed, 164 insertions(+), 63 deletions(-) diff --git a/crates/transport/src/frame/conn/client.rs b/crates/transport/src/frame/conn/client.rs index 35afff0bc..b3b86b924 100644 --- a/crates/transport/src/frame/conn/client.rs +++ b/crates/transport/src/frame/conn/client.rs @@ -11,7 +11,7 @@ use crate::frame::conn::{Incoming, Outgoing}; use crate::frame::{ConnHandler, PROTOCOL}; /// Encodes the header of an invocation, which is followed by `params_len` bytes of -/// parameters, to a `BytesMut` +/// parameters, to a `BytesMut`. If `params_len` is 0, only the header is encoded. /// /// This is low-level API, most users should use [`invoke`]. #[instrument(level = "trace", skip_all)] @@ -31,8 +31,10 @@ pub fn encode_invocation( buf.put_u8(PROTOCOL); CoreNameEncoder.encode(instance, buf)?; CoreNameEncoder.encode(func, buf)?; - buf.put_u8(0); - Leb128Encoder.encode(params_len, buf)?; + if params_len > 0 { + buf.put_u8(0); + Leb128Encoder.encode(params_len, buf)?; + } Ok(()) } diff --git a/crates/transport/src/frame/conn/mod.rs b/crates/transport/src/frame/conn/mod.rs index 379760b1b..ab3c458c2 100644 --- a/crates/transport/src/frame/conn/mod.rs +++ b/crates/transport/src/frame/conn/mod.rs @@ -94,23 +94,30 @@ impl Header { } } +/// Sender of a subscribed path +#[derive(Debug)] +enum TxSlot { + Open(mpsc::Sender>), + Closed, +} + /// Index trie containing async stream subscriptions #[derive(Debug, Default)] enum IndexTrie { #[default] Empty, Leaf { - tx: Option>>, + tx: Option, rx: Option>>, }, IndexNode { - tx: Option>>, + tx: Option, rx: Option>>, nested: Vec>, }, // TODO: Add partially-indexed `WildcardIndexNode` WildcardNode { - tx: Option>>, + tx: Option, rx: Option>>, nested: Option>, }, @@ -131,7 +138,10 @@ impl<'a> ), ) -> Self { match path { - [] => Self::Leaf { tx: Some(tx), rx }, + [] => Self::Leaf { + tx: Some(TxSlot::Open(tx)), + rx, + }, [None, path @ ..] => Self::WildcardNode { tx: None, rx: None, @@ -190,13 +200,23 @@ impl]>> FromIterator

for IndexTrie { } impl IndexTrie { + /// Builds a trie of `paths`, which sends frames received on the root path to `tx` + fn new]>>( + paths: impl IntoIterator, + tx: mpsc::Sender>, + ) -> Self { + let mut index: Self = paths + .into_iter() + .filter(|path| !path.as_ref().is_empty()) + .collect(); + index.insert(&[], tx, None); + index + } + /// Calls `f` with the sender and receiver of each node in the trie fn for_each( &mut self, - f: &mut impl FnMut( - &mut Option>>, - &mut Option>>, - ), + f: &mut impl FnMut(&mut Option, &mut Option>>), ) { match self { Self::Empty => {} @@ -261,14 +281,15 @@ impl IndexTrie { } } - /// Gets a sender - #[instrument(level = "trace", skip(self), ret(level = "trace"))] - fn get_tx(&mut self, path: &[usize]) -> Option>> { + /// Gets the sender slot of a subscription + #[instrument(level = "trace", skip(self))] + fn get_tx_mut(&mut self, path: &[usize]) -> Option<&mut TxSlot> { let Some((i, path)) = path.split_first() else { return match self { Self::Empty => None, - Self::Leaf { tx, .. } => tx.clone(), - Self::IndexNode { tx, .. } | Self::WildcardNode { tx, .. } => tx.clone(), + Self::Leaf { tx, .. } + | Self::IndexNode { tx, .. } + | Self::WildcardNode { tx, .. } => tx.as_mut(), }; }; match self { @@ -276,7 +297,7 @@ impl IndexTrie { Self::IndexNode { nested, .. } => { let nested = nested.get_mut(*i)?; let nested = nested.as_mut()?; - nested.get_tx(path) + nested.get_tx_mut(path) } // TODO: Demux the subscription //Self::WildcardNode { ref mut nested, .. } => { // nested.as_mut().and_then(|nested| nested.take(path)) @@ -284,11 +305,13 @@ impl IndexTrie { } } - /// Closes all senders in the trie + /// Closes all open senders in the trie #[instrument(level = "trace", skip(self), ret(level = "trace"))] fn close_tx(&mut self) { self.for_each(&mut |tx, _| { - tx.take(); + if let Some(TxSlot::Open(..)) = tx { + tx.take(); + } }); } @@ -330,7 +353,7 @@ impl IndexTrie { } Self::IndexNode { tx, rx, nested } => match (&tx, &rx, path) { (None, None, []) => { - *tx = Some(sender); + *tx = Some(TxSlot::Open(sender)); *rx = receiver; true } @@ -351,7 +374,7 @@ impl IndexTrie { }, Self::WildcardNode { tx, rx, nested } => match (&tx, &rx, path) { (None, None, []) => { - *tx = Some(sender); + *tx = Some(TxSlot::Open(sender)); *rx = receiver; true } @@ -413,7 +436,7 @@ impl Incoming { { let (rx_tx, rx_rx) = mpsc::channel(128); let state = Arc::new(IngressState { - index: std::sync::Mutex::new(paths.into_iter().collect()), + index: std::sync::Mutex::new(IndexTrie::new(paths, rx_tx)), err: std::sync::OnceLock::default(), unprocessed: AtomicBool::default(), }); @@ -424,11 +447,10 @@ impl Incoming { async move { let res = async { let mut io = pin!(async { - let res = ingress(&mut rx, &state.index, &rx_tx).await; + let res = ingress(&mut rx, &state.index).await; if let Err(err) = &res { _ = state.err.set(copy_io_error(err)); } - drop(rx_tx); trace!("shutting down index trie"); state .index @@ -534,7 +556,9 @@ impl AsyncRead for Incoming { trace!(buf = ?buf.filled(), "read buffer"); if buf.filled().is_empty() { self.rx.take(); - if let Some(err) = self.state.err.get() { + if let Some(err) = self.state.err.get() + && !self.state.is_closed(&self.path) + { return Poll::Ready(Err(copy_io_error(err))); } } @@ -548,6 +572,14 @@ struct IngressState { unprocessed: AtomicBool, } +impl IngressState { + /// Returns whether an empty frame closed the `path` + fn is_closed(&self, path: &[usize]) -> bool { + let mut index = self.index.lock().unwrap_or_else(PoisonError::into_inner); + matches!(index.get_tx_mut(path), Some(TxSlot::Closed)) + } +} + enum IngressGuard { Abort { _task: AbortOnDropHandle<()> }, Drain { _dropped: oneshot::Sender<()> }, @@ -637,6 +669,7 @@ impl Outgoing { } /// Writes `buf` to the stream as a single frame, without copying it. + /// Empty `buf` is not written, since an empty frame closes the stream, see [`Self::close`]. pub async fn write_bytes(&mut self, buf: Bytes) -> std::io::Result<()> { if buf.is_empty() { return Ok(()); @@ -644,6 +677,12 @@ impl Outgoing { self.reserve().await?; self.send_reserved(buf) } + + /// Closes the stream by writing an empty frame. No more data may be written to it afterwards. + pub async fn close(&mut self) -> std::io::Result<()> { + self.reserve().await?; + self.send_reserved(Bytes::default()) + } } impl AsyncWrite for Outgoing { @@ -677,7 +716,6 @@ impl AsyncWrite for Outgoing { async fn ingress( mut rx: impl AsyncRead + Unpin, index: &std::sync::Mutex, - param_tx: &mpsc::Sender>, ) -> std::io::Result<()> { loop { trace!("reading path length"); @@ -692,8 +730,8 @@ async fn ingress( let n = usize::try_from(n) .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?; trace!(n, "read path length"); - let tx = if n == 0 { - param_tx + let path = if n == 0 { + Vec::default() } else { let mut path = Vec::with_capacity(n.min(MAX_INITIAL_PATH_CAPACITY)); for i in 0..n { @@ -704,21 +742,34 @@ async fn ingress( path.push(p); } trace!(?path, "read path"); - + path + }; + trace!("reading data length"); + let n = rx.read_u32_leb128().await?; + trace!(n, "read data length"); + let tx = { trace!("locking index trie"); - let mut index = index + let mut trie = index .lock() .map_err(|err| std::io::Error::other(err.to_string()))?; - &index.get_tx(&path).ok_or_else(|| { + let tx = trie.get_tx_mut(&path).ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotFound, format!("`{path:?}` subscription not found"), ) - })? + })?; + if n == 0 { + trace!(?path, "closing path"); + if let TxSlot::Closed = mem::replace(tx, TxSlot::Closed) { + return Err(error_closed(&path)); + } + continue; + } + let TxSlot::Open(tx) = tx else { + return Err(error_closed(&path)); + }; + tx.clone() }; - trace!("reading data length"); - let n = rx.read_u32_leb128().await?; - trace!(n, "read data length"); let mut data = (&mut rx).take(n.into()); while data.limit() > 0 { let k = usize::try_from(data.limit()) @@ -750,6 +801,14 @@ fn close_rx(state: &IngressState) -> std::io::Result<()> { Ok(()) } +/// Returns the error for a frame received on a closed `path` +fn error_closed(path: &[usize]) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("received a frame on closed path `{path:?}`"), + ) +} + /// Returns the error for received data, which was not processed fn error_unprocessed() -> std::io::Error { std::io::Error::new( @@ -892,6 +951,21 @@ mod tests { done(done_rx).await.unwrap(); } + #[test_log::test(tokio::test)] + async fn incoming_empty_frame_closes_path() { + let (mut tx, mut rx, done_rx) = incoming(true); + let mut sub = rx.index(&[0]).unwrap(); + tx.write_all(&[0x00, 0x00]).await.unwrap(); + tx.write_all(&FRAME).await.unwrap(); + tx.write_all(&[0x01, 0x00, 0x00]).await.unwrap(); + tx.write_all(&[0x01, 0x01, 0x01, 0x2a]).await.unwrap(); + assert_eq!(failed(done_rx).await, ErrorKind::NotFound); + let mut buf = vec![]; + sub.read_to_end(&mut buf).await.unwrap(); + assert_eq!(buf, [0x2a]); + assert_eq!(rx.read_to_end(&mut Vec::default()).await.unwrap(), 0); + } + #[test_log::test(tokio::test)] async fn incoming_drop_rejects_data() { let (mut tx, rx, done_rx) = incoming(true); @@ -961,42 +1035,49 @@ mod tests { } #[test_log::test(tokio::test)] - async fn ingress_truncated_path() { - let index = std::sync::Mutex::new(IndexTrie::Empty); - let (tx, _rx) = mpsc::channel(1); - let err = ingress([0x02, 0x00].as_slice(), &index, &tx) - .await - .unwrap_err(); - assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof); + async fn ingress_rejects_invalid_frames() { + for (frames, kind) in [ + ([0x02, 0x00].as_slice(), ErrorKind::UnexpectedEof), + ( + &[0x00, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x42], + ErrorKind::UnexpectedEof, + ), + (&[0x00, 0x00, 0x00, 0x01, 0x2a], ErrorKind::InvalidData), + (&[0x00, 0x00, 0x00, 0x00], ErrorKind::InvalidData), + ( + &[0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x2a], + ErrorKind::InvalidData, + ), + ( + &[0x01, 0x00, 0x00, 0x01, 0x00, 0x00], + ErrorKind::InvalidData, + ), + (&[0x01, 0x01, 0x00], ErrorKind::NotFound), + (&[0x01, 0x01, 0x01, 0x2a], ErrorKind::NotFound), + ] { + let (tx, _rx) = mpsc::channel(1); + let index = std::sync::Mutex::new(IndexTrie::new( + [[Some(0)].as_slice(), &[Some(1), Some(0)]], + tx, + )); + let err = ingress(frames, &index).await.unwrap_err(); + assert_eq!(err.kind(), kind, "{frames:02x?}"); + } } #[test_log::test(tokio::test)] async fn ingress_chunked_data() { - let index = std::sync::Mutex::new(IndexTrie::Empty); let (tx, mut rx) = mpsc::channel(4); + let index = std::sync::Mutex::new(IndexTrie::new::<[_; 0]>([], tx)); let n = DEFAULT_MAX_INITIAL_CAPACITY + 1; let mut frame = vec![0x00, 0x81, 0x80, 0x40]; frame.resize(frame.len() + n, 0x42); - ingress(frame.as_slice(), &index, &tx).await.unwrap(); - drop(tx); + ingress(frame.as_slice(), &index).await.unwrap(); + drop(index); let mut chunks = vec![]; while let Some(chunk) = rx.recv().await { chunks.push(chunk.unwrap().len()); } assert_eq!(chunks, [DEFAULT_MAX_INITIAL_CAPACITY, 1]); } - - #[test_log::test(tokio::test)] - async fn ingress_truncated_data() { - let index = std::sync::Mutex::new(IndexTrie::Empty); - let (tx, _rx) = mpsc::channel(1); - let err = ingress( - [0x00, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x42].as_slice(), - &index, - &tx, - ) - .await - .unwrap_err(); - assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof); - } } diff --git a/crates/transport/src/value.rs b/crates/transport/src/value.rs index 12b2d94e4..8d983e8c9 100644 --- a/crates/transport/src/value.rs +++ b/crates/transport/src/value.rs @@ -443,7 +443,11 @@ impl Sink for Outgoing { let mut enc = T::Encoder::default(); let mut buf = BytesMut::default(); enc.encode(item, &mut buf)?; - self.write_bytes(buf.freeze()).await?; + if buf.is_empty() { + self.close().await?; + } else { + self.write_bytes(buf.freeze()).await?; + } match enc.take_deferred() { Some(f) => f(self, Vec::default()).await, _ => Ok(()), @@ -1922,17 +1926,19 @@ where return Err(std::io::ErrorKind::UnexpectedEof.into()); }; let item = item?; + let rx = dec.decoder_mut().take_deferred(); + if rx.is_none() { + ensure_processed(&dec)?; + } if tx.send(item).is_err() { return Err(error_receiver_closed()); } - if let Some(rx) = dec.decoder_mut().take_deferred() { + if let Some(rx) = rx { let mut buf = mem::take(dec.read_buffer_mut()); let mut r = dec.into_inner(); buf.unsplit(mem::take(&mut r.buffer)); r.buffer = buf; rx(r, Vec::default()).await?; - } else { - ensure_processed(&dec)?; } Ok(()) } @@ -2636,6 +2642,11 @@ mod tests { let (_fut, f) = pending::(); let err = f(buffered(b"\x03foo\x2a"), Vec::default()).await; assert_eq!(err.unwrap_err().kind(), ErrorKind::InvalidData); + + let (fut, f) = pending:: + Send>>>(); + let err = f(buffered(b"\x2a"), Vec::default()).await; + assert_eq!(err.unwrap_err().kind(), ErrorKind::InvalidData); + assert!(fut.now_or_never().is_none()); } #[test_log::test(tokio::test)] @@ -2649,6 +2660,13 @@ mod tests { assert_eq!(err.unwrap_err().kind(), ErrorKind::InvalidData); } + #[test_log::test(tokio::test)] + async fn future_unit_resolves_on_end() { + let (fut, f) = pending:: + Send>>>(); + f(buffered(b""), Vec::default()).await.unwrap(); + fut.await; + } + #[test_log::test(tokio::test)] async fn future_rejects_dropped_receiver() { let (fut, f) = pending::(); From e9ba0742658c695b8177b2168fd1d870470aa084 Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Fri, 23 Oct 2026 19:33:59 +0200 Subject: [PATCH 06/10] fix(js): close paths with empty frames An empty frame closes its sub-stream, and any further frame for it fails the invocation. The closed state is kept in the sub-stream trie, so it is forgotten along with finished sub-streams. A `stream` whose sub-stream ends before its empty terminator chunk fails the invocation. `Outgoing.write` skips empty data, and `Outgoing.close` sends an empty frame. Assisted-by: claude:claude-opus-5-5 --- js/src/index.d.ts | 1 + js/src/mux.js | 31 +++++++++++-- js/src/session.js | 5 --- js/test/session.test.js | 99 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/js/src/index.d.ts b/js/src/index.d.ts index 4d1027462..af268afe1 100644 --- a/js/src/index.d.ts +++ b/js/src/index.d.ts @@ -223,6 +223,7 @@ export class Outgoing { constructor(mux: Mux, path: number[]); path: number[]; write(data: Uint8Array): Promise; + close(): Promise; index(rel: number[]): Outgoing; } diff --git a/js/src/mux.js b/js/src/mux.js index 7e4b93283..b9b076f1c 100644 --- a/js/src/mux.js +++ b/js/src/mux.js @@ -34,6 +34,7 @@ export const DISCARDED = new Error("sub-stream discarded"); * @property {number[]} path * @property {Chan} chan * @property {boolean} empty whether only empty frames arrived for it + * @property {boolean} closed whether an empty frame closed it */ /** @@ -52,6 +53,7 @@ export const DISCARDED = new Error("sub-stream discarded"); * @property {number} decided the number of stream elements known, whose * children are all in the trie * @property {boolean} done whether all its own data has been read + * @property {boolean} closed whether an empty frame closed it * @property {Set | undefined} pruned the indices of the children * forgotten once finished, beyond those of the `decided` stream elements * @property {number} discardFrom `Infinity`, unless the node is discarded: then @@ -67,6 +69,7 @@ const node = (sealed) => ({ sealed, decided: 0, done: false, + closed: false, pruned: undefined, discardFrom: Infinity, pending: undefined, @@ -150,11 +153,25 @@ export class Outgoing { this.path = path; } - /** @param {Uint8Array} data @returns {Promise} */ + /** + * Write `data` as a frame, unless it is empty, since an empty frame closes + * the sub-stream. + * @param {Uint8Array} data @returns {Promise} + */ write(data) { + if (data.length === 0) return Promise.resolve(); return this.mux._writeFrame(this.path, data); } + /** + * Close the sub-stream by writing an empty frame. No more data may be + * written to it afterwards. + * @returns {Promise} + */ + close() { + return this.mux._writeFrame(this.path, new Uint8Array(0)); + } + /** @param {number[]} rel @returns {Outgoing} */ index(rel) { return new Outgoing(this.mux, [...this.path, ...rel]); @@ -216,8 +233,10 @@ export class Mux { if (!(await m.fill(1))) break; const [path, n] = await m.decode(readFrameHeader); if (n === 0) { - // Peers may send empty frames, even for sub-streams that are finished. - this._route(path, true); + // An empty frame closes its sub-stream, which may already be finished. + this._route(path, true)?.close(); + const closed = this._get(path); + if (closed) closed.closed = true; continue; } const chan = this._route(path); @@ -253,6 +272,7 @@ export class Mux { } n = child; } + if (n.closed) unexpected(path); if (n.done) return empty ? null : unexpected(path); if (n.discardFrom !== Infinity) return null; return n.chan ?? unexpected(path); @@ -270,8 +290,10 @@ export class Mux { if (found === null) return undefined; if (found instanceof Chan) return found; let pending = found.at.pending?.get(found.i)?.get(key(path)); - if (!pending) attach(found, (pending = { path, chan: new Chan(), empty })); + if (pending?.closed) unexpected(path); + if (!pending) attach(found, (pending = { path, chan: new Chan(), empty, closed: false })); pending.empty &&= empty; + pending.closed ||= empty; return pending.chan; } @@ -354,6 +376,7 @@ export class Mux { n.sealed = !e.nested; if (n.chan) return; n.chan = t?.pending?.chan ?? new Chan(); + n.closed = t?.pending?.closed ?? false; n.chan.capacity = CAPACITY; if (this.inboundClosed) n.chan.close(this.inboundError); }); diff --git a/js/src/session.js b/js/src/session.js index 4d56fa395..707f802ed 100644 --- a/js/src/session.js +++ b/js/src/session.js @@ -248,11 +248,6 @@ async function readStream(mux, path, elem, queue, ar) { const tasks = []; try { for (;;) { - // The peer may close the connection instead of sending the empty terminator. - if (!(await ar.fill(1))) { - mux.finish(path); - break; - } const len = await ar.decode((r) => atomic(r, readVarU32)); if (len === 0) { mux.finish(path, ar); diff --git a/js/test/session.test.js b/js/test/session.test.js index 80734ffae..d4ceb385f 100644 --- a/js/test/session.test.js +++ b/js/test/session.test.js @@ -600,7 +600,7 @@ test( const frames = []; const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); await peer.outgoing([]).write(Uint8Array.of(0, 0)); - await peer.outgoing([0]).write(new Uint8Array(0)); + await peer.outgoing([0]).close(); await peer.outgoing([1]).write(Uint8Array.of(1, 7)); await peer.outgoing([1]).write(Uint8Array.of(0)); const transport = { read: async () => frames.shift() ?? null, write: () => {} }; @@ -611,6 +611,28 @@ test( }, ); +test("empty writes send no frames", async () => { + const frames = []; + const mux = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); + await mux.outgoing([]).write(new Uint8Array(0)); + await mux.outgoing([0]).write(new Uint8Array(0)); + assert.deepEqual(frames, []); +}); + +test("an invocation with empty parameters sends just the header", async () => { + const frames = []; + let closed = false; + const transport = { + read: async () => null, + write: (bytes) => void frames.push(bytes), + closeWrite: () => void (closed = true), + }; + const { done } = await invoke(transport, "foo", "bar", [], [], []); + await done; + assert.ok(closed); + assert.deepEqual(Buffer.concat(frames), Buffer.from("\x00\x03foo\x03bar", "latin1")); +}); + /** A transport fed by the frames a peer writes, until `inbound` is closed. */ function feed() { const inbound = new Chan(); @@ -634,7 +656,7 @@ test("an empty frame for a finished sub-stream is accepted", { timeout: 1000 }, const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); assert.deepEqual(await collect(results[0]), [7]); await done; - await peer.outgoing([0]).write(new Uint8Array(0)); + await peer.outgoing([0]).close(); inbound.close(); const outcome = await Promise.race([ aborted, @@ -645,6 +667,40 @@ test("an empty frame for a finished sub-stream is accepted", { timeout: 1000 }, assert.equal(outcome, "ok"); }); +test( + "a second empty frame for a sub-stream not yet declared fails it", + { timeout: 1000 }, + async () => { + const { inbound, peer, transport, aborted } = feed(); + await peer.outgoing([]).write(Uint8Array.of(0)); + await peer.outgoing([0, 0]).close(); + await peer.outgoing([0, 0]).close(); + await peer.outgoing([0]).write(Uint8Array.of(1, 1, 0)); + await peer.outgoing([0]).write(Uint8Array.of(0)); + inbound.close(); + const ty = [t.stream(t.option(t.future(t.tuple())))]; + await invoke(transport, STREAMS, "get", [], [], ty); + assert.match(String(await aborted), /unexpected data for sub-stream \[0,0\]/); + }, +); + +test( + "data after an empty frame for a sub-stream not yet declared fails it", + { timeout: 1000 }, + async () => { + const { inbound, peer, transport, aborted } = feed(); + await peer.outgoing([]).write(Uint8Array.of(0)); + await peer.outgoing([0, 0]).close(); + await peer.outgoing([0, 0]).write(Uint8Array.of(7)); + await peer.outgoing([0]).write(Uint8Array.of(1, 1, 0)); + await peer.outgoing([0]).write(Uint8Array.of(0)); + inbound.close(); + const ty = [t.stream(t.option(t.future(t.u8)))]; + await invoke(transport, STREAMS, "get", [], [], ty); + assert.match(String(await aborted), /unexpected data for sub-stream \[0,0\]/); + }, +); + test( "an empty frame for a sub-stream the reply does not carry fails it after a sibling's", { timeout: 1000 }, @@ -655,7 +711,7 @@ test( const ty = [t.option(t.stream(t.u8)), t.future(t.u8)]; const { results } = await invoke(transport, STREAMS, "get", [], [], ty); assert.equal(await results[1], 7); - await peer.outgoing([0]).write(new Uint8Array(0)); + await peer.outgoing([0]).close(); inbound.close(); assert.match(String(await aborted), /unexpected data for sub-stream \[0\]/); }, @@ -668,7 +724,7 @@ test( const frames = []; const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); await peer.outgoing([]).write(Uint8Array.of(0)); - await peer.outgoing([0, 0]).write(new Uint8Array(0)); + await peer.outgoing([0, 0]).close(); await peer.outgoing([0]).write(Uint8Array.of(1, 0)); await peer.outgoing([0]).write(Uint8Array.of(0)); const transport = { read: async () => frames.shift() ?? null, write: () => {} }; @@ -896,3 +952,38 @@ test("stream> yields chunks of byte streams", async () => { ]); await Promise.all([done, serving]); }); + +test("an empty frame closes its sub-stream", { timeout: 1000 }, async () => { + const { peer, transport } = feed(); + await peer.outgoing([]).write(Uint8Array.of(0)); + await peer.outgoing([0]).write(Uint8Array.of(1, 7, 0)); + await peer.outgoing([0]).close(); + const { results } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); + assert.deepEqual(await collect(results[0]), [7]); +}); + +test("a stream closed before its terminator fails", { timeout: 1000 }, async () => { + const { peer, transport, aborted } = feed(); + await peer.outgoing([]).write(Uint8Array.of(0)); + await peer.outgoing([0]).write(Uint8Array.of(1, 7)); + await peer.outgoing([0]).close(); + const { results } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); + await assert.rejects(collect(results[0]), /unexpected end of input/); + assert.match(String(await aborted), /unexpected end of input/); +}); + +for (const [name, frame] of [ + ["data", (out) => out.write(Uint8Array.of(0))], + ["an empty frame", (out) => out.close()], +]) { + test(`${name} for a closed sub-stream fails it`, { timeout: 1000 }, async () => { + const { inbound, peer, transport, aborted } = feed(); + await peer.outgoing([]).write(Uint8Array.of(0)); + await peer.outgoing([0]).write(Uint8Array.of(1, 7)); + await peer.outgoing([0]).close(); + await frame(peer.outgoing([0])); + inbound.close(); + await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); + assert.match(String(await aborted), /unexpected data for sub-stream \[0\]/); + }); +} From 457fc32aad87b60bbb0ff8e9a01a60525fda349f Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Fri, 23 Oct 2026 19:54:52 +0200 Subject: [PATCH 07/10] feat(js): support futures without a payload `t.future()` describes a bare `future`. A pending one closes its sub-stream once resolved, and is resolved to `undefined` once its sub-stream is closed or the transport ends, failing if any data arrives on it. Assisted-by: claude:claude-opus-5-5 --- js/README.md | 11 +++++---- js/src/index.d.ts | 8 +++---- js/src/mux.js | 2 +- js/src/session.js | 17 +++++++++----- js/src/types.js | 7 +++--- js/src/value.js | 4 ++-- js/test/session.test.js | 52 ++++++++++++++++++++++++++++++++++++++++- js/test/value.test.js | 4 +++- 8 files changed, 82 insertions(+), 23 deletions(-) diff --git a/js/README.md b/js/README.md index f85c2d955..e81b99a1d 100644 --- a/js/README.md +++ b/js/README.md @@ -60,11 +60,12 @@ that I/O fails, the invocation fails and the transport is aborted. ### Streams and futures -`t.stream(elem)` and `t.future(some)` describe asynchronous values. A `stream` -parameter is given as an (async-)iterable of chunks (a `Uint8Array` for -`stream`, an array of elements otherwise); a `future` as a value, a promise, -or a thunk. Decoded `stream` results are async iterables of chunks and `future` -results are promises: +`t.stream(elem)` and `t.future(some)` describe asynchronous values, and +`t.future()` a bare `future` without a payload. A `stream` parameter is given as +an (async-)iterable of chunks (a `Uint8Array` for `stream`, an array of +elements otherwise); a `future` as a value, a promise, or a thunk. Decoded +`stream` results are async iterables of chunks and `future` results are +promises, resolving to `undefined` for a bare `future`: ```js import { t, invoke, fromWebStreams } from "@bytecodealliance/wrpc"; diff --git a/js/src/index.d.ts b/js/src/index.d.ts index af268afe1..ab734754f 100644 --- a/js/src/index.d.ts +++ b/js/src/index.d.ts @@ -26,7 +26,7 @@ export type Type = | { kind: "variant"; cases: { name: string; ty: Type | null }[] } | { kind: "enum"; names: string[] } | { kind: "flags"; names: string[] } - | { kind: "future"; some: Type } + | { kind: "future"; some: Type | null } | { kind: "stream"; elem: Type } | { kind: "own" | "borrow"; name: string }; @@ -54,7 +54,7 @@ export type DecodeDeferred = | { path: number[]; kind: "future"; - ty: Type; + ty: Type | null; resolve: (value: Value) => void; reject: (err: unknown) => void; } @@ -79,7 +79,7 @@ export namespace types { export const string: Type; export function list(elem: Type): Type; export function option(some: Type): Type; - export function future(some: Type): Type; + export function future(some?: Type | null): Type; export function stream(elem: Type): Type; export function result(ok?: Type | null, err?: Type | null): Type; export function tuple(...elems: Type[]): Type; @@ -181,7 +181,7 @@ export function encodeValue( w: Writer, ty: Type, v: Value, - sink?: { path: number[]; kind: "stream" | "future"; ty: Type; value: Value }[], + sink?: { path: number[]; kind: "stream" | "future"; ty: Type | null; value: Value }[], path?: number[], ): void; export function decodeValue(r: Reader, ty: Type, sink?: DecodeDeferred[], path?: number[]): Value; diff --git a/js/src/mux.js b/js/src/mux.js index b9b076f1c..22b9109ea 100644 --- a/js/src/mux.js +++ b/js/src/mux.js @@ -103,7 +103,7 @@ function* readFrameHeader(r) { } /** @param {number[]} path */ -const unexpectedError = (path) => new Error(`unexpected data for sub-stream [${path}]`); +export const unexpectedError = (path) => new Error(`unexpected data for sub-stream [${path}]`); /** @param {number[]} path @returns {never} */ function unexpected(path) { diff --git a/js/src/session.js b/js/src/session.js index 707f802ed..71784d69a 100644 --- a/js/src/session.js +++ b/js/src/session.js @@ -10,7 +10,7 @@ // writer task per sub-stream. import { Writer, atomic, readVarU32 } from "./io.js"; -import { DISCARDED, Mux, PROTOCOL } from "./mux.js"; +import { DISCARDED, Mux, PROTOCOL, unexpectedError } from "./mux.js"; import { decodeSteps, encodeValue, hasAsync, readChunk, toBytes } from "./value.js"; /** @typedef {import("./index.d.ts").Type} Type */ @@ -63,14 +63,15 @@ function writeDeferred(out, entry) { : writeStream(out, entry.ty, entry.value); } -/** @param {Outgoing} out @param {Type} ty @param {any} value */ +/** @param {Outgoing} out @param {Type | null} ty @param {any} value */ async function writeFuture(out, ty, value) { const v = await resolveFuture(value); const w = new Writer(); /** @type {any[]} */ const sink = []; - encodeValue(w, ty, v, sink, []); - await out.write(w.finish()); + if (ty) encodeValue(w, ty, v, sink, []); + const bytes = w.finish(); + await (bytes.length > 0 ? out.write(bytes) : out.close()); await Promise.all(sink.map((e) => writeDeferred(out.index(e.path), e))); } @@ -167,7 +168,9 @@ async function receiveTuple(mux, root, types) { function expect(mux, path, sink) { mux.seal( path, - sink.flatMap((e) => (e.kind === "inline" ? [] : [{ path: e.path, nested: hasAsync(e.ty) }])), + sink.flatMap((e) => + e.kind === "inline" ? [] : [{ path: e.path, nested: !!e.ty && hasAsync(e.ty) }], + ), ); } @@ -213,7 +216,9 @@ async function readDeferred(mux, path, entry, ar) { async function readFuture(mux, path, entry, ar) { let decoded; try { - decoded = await decodeOne(ar, entry.ty); + if (entry.ty) decoded = await decodeOne(ar, entry.ty); + else if (await ar.fill(1)) throw unexpectedError(path); + else decoded = { value: undefined, sink: [] }; } catch (err) { entry.reject(err); if (err === DISCARDED) return; diff --git a/js/src/types.js b/js/src/types.js index 61ee461ec..dcd5a2309 100644 --- a/js/src/types.js +++ b/js/src/types.js @@ -40,10 +40,11 @@ export const option = (some) => ({ kind: "option", some }); /** * `future`. Carries a single asynchronous value, multiplexed over the * invocation's framed stream. Requires the async session API (`invoke` / - * `accept`); the synchronous `encode`/`decode` cannot transmit it. - * @param {Type} some + * `accept`); the synchronous `encode`/`decode` cannot transmit it. Pass no + * type for a bare `future` without a payload. + * @param {Type | null} [some] */ -export const future = (some) => ({ kind: "future", some }); +export const future = (some = null) => ({ kind: "future", some }); /** * `stream`. Carries an asynchronous sequence of `elem` chunks, diff --git a/js/src/value.js b/js/src/value.js index 384ead3cd..a3d8e51c0 100644 --- a/js/src/value.js +++ b/js/src/value.js @@ -178,7 +178,7 @@ export function* readChunk(r, elem, n, sink, path) { * @param {Writer} w * @param {Type} ty * @param {any} v - * @param {{ path: number[], kind: "stream" | "future", ty: Type, value: any }[]} [sink] + * @param {{ path: number[], kind: "stream" | "future", ty: Type | null, value: any }[]} [sink] * @param {number[]} [path] relative index path to `v` (only tracked with `sink`) */ export function encodeValue(w, ty, v, sink, path) { @@ -417,7 +417,7 @@ export function* decodeSteps(r, ty, sink, path) { } case "future": { if (yield* atomic(r, optionStatus)) { - return Promise.resolve(yield* decodeSteps(r, ty.some, sink, path)); + return Promise.resolve(ty.some ? yield* decodeSteps(r, ty.some, sink, path) : undefined); } if (!sink) throw new Error(asyncRequired(ty.kind)); /** @type {(value: any) => void} */ diff --git a/js/test/session.test.js b/js/test/session.test.js index d4ceb385f..07c75a83a 100644 --- a/js/test/session.test.js +++ b/js/test/session.test.js @@ -633,6 +633,17 @@ test("an invocation with empty parameters sends just the header", async () => { assert.deepEqual(Buffer.concat(frames), Buffer.from("\x00\x03foo\x03bar", "latin1")); }); +test("a future without a payload closes its sub-stream", async () => { + const frames = []; + const transport = { read: async () => null, write: (bytes) => void frames.push(bytes) }; + const { done } = await invoke(transport, "foo", "bar", [t.future()], [Promise.resolve()], []); + await done; + assert.deepEqual( + Buffer.concat(frames), + Buffer.from("\x00\x03foo\x03bar\x00\x01\x00\x01\x00\x00", "latin1"), + ); +}); + /** A transport fed by the frames a peer writes, until `inbound` is closed. */ function feed() { const inbound = new Chan(); @@ -648,6 +659,45 @@ function feed() { return { inbound, peer, transport, aborted }; } +test( + "a future without a payload resolves once its sub-stream is closed", + { timeout: 1000 }, + async () => { + const { inbound, peer, transport } = feed(); + await peer.outgoing([]).write(Uint8Array.of(0)); + const ty = [t.future()]; + const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); + let resolved = false; + results[0].then(() => (resolved = true)); + await tick(); + await tick(); + assert.equal(resolved, false); + await peer.outgoing([0]).close(); + assert.equal(await results[0], undefined); + inbound.close(); + await done; + }, +); + +test("a future without a payload resolves once the transport ends", { timeout: 1000 }, async () => { + const { inbound, peer, transport } = feed(); + await peer.outgoing([]).write(Uint8Array.of(0)); + inbound.close(); + const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.future()]); + assert.equal(await results[0], undefined); + await done; +}); + +test("a future without a payload fails if data arrives on it", { timeout: 1000 }, async () => { + const { inbound, peer, transport } = feed(); + await peer.outgoing([]).write(Uint8Array.of(0)); + await peer.outgoing([0]).write(Uint8Array.of(0)); + inbound.close(); + const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.future()]); + await assert.rejects(results[0], /unexpected data for sub-stream \[0\]/); + await assert.rejects(done, /unexpected data for sub-stream \[0\]/); +}); + test("an empty frame for a finished sub-stream is accepted", { timeout: 1000 }, async () => { const { inbound, peer, transport, aborted } = feed(); await peer.outgoing([]).write(Uint8Array.of(0)); @@ -678,7 +728,7 @@ test( await peer.outgoing([0]).write(Uint8Array.of(1, 1, 0)); await peer.outgoing([0]).write(Uint8Array.of(0)); inbound.close(); - const ty = [t.stream(t.option(t.future(t.tuple())))]; + const ty = [t.stream(t.option(t.future()))]; await invoke(transport, STREAMS, "get", [], [], ty); assert.match(String(await aborted), /unexpected data for sub-stream \[0,0\]/); }, diff --git a/js/test/value.test.js b/js/test/value.test.js index 68c6b2453..8228f9ecb 100644 --- a/js/test/value.test.js +++ b/js/test/value.test.js @@ -264,7 +264,7 @@ test("stream and future cannot be encoded or decoded synchronously", () => { assert.throws(() => decode(t.future(t.string), new Uint8Array([0])), /async session API/); }); -test("ready streams are decoded synchronously", async () => { +test("ready streams and futures are decoded synchronously", async () => { const collect = async (chunks) => { const out = []; for await (const chunk of chunks) out.push(chunk); @@ -277,6 +277,8 @@ test("ready streams are decoded synchronously", async () => { ["a"], ]); assert.deepEqual(await collect(decode(t.stream(t.u8), Uint8Array.of(1, 0))), []); + assert.equal(await decode(t.future(t.u8), Uint8Array.of(1, 7)), 7); + assert.equal(await decode(t.future(), Uint8Array.of(1)), undefined); }); test("the wasi:keyvalue store types", () => { From e5dfba3f3df648756bd77fd9b55d71aebd2a2095 Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Mon, 26 Oct 2026 15:11:34 +0100 Subject: [PATCH 08/10] fix(js)!: reject pending values in list and stream elements Like the Rust implementation, fail an invocation receiving `list` or `stream` elements that carry pending `stream` or `future` values, instead of tracking sub-streams nested in elements, and fail sending them in `stream` elements. The expected sub-streams are then bounded by the types of the decoded values, so the mux keeps every node until the invocation ends instead of pruning finished ones, and rejects any frame on a path an empty frame already closed, even once its sub-stream finished. Assisted-by: claude:claude-opus-5-5 --- js/README.md | 8 +- js/src/index.d.ts | 18 +- js/src/io.js | 4 - js/src/mux.js | 151 +++-------- js/src/session.js | 76 +----- js/src/value.js | 21 +- js/test/session.test.js | 584 ++++++++++------------------------------ 7 files changed, 206 insertions(+), 656 deletions(-) diff --git a/js/README.md b/js/README.md index e81b99a1d..6bcb16899 100644 --- a/js/README.md +++ b/js/README.md @@ -99,9 +99,11 @@ consumed, as in the Rust implementation. So consume streams concurrently with each other, with awaiting futures, and with `done`: awaiting a future while an earlier stream is left unread can wait forever, since the future's data may be queued behind the stream's on the transport. Leave a `for await` loop early (or call -`cancel()`) to discard the rest of a stream you no longer need, including the -streams and futures nested in the chunks you did not receive. Those nested in -the chunks you did receive stay live, so consume or cancel them too. +`cancel()`) to discard the rest of a stream you no longer need. + +As in the Rust implementation, received `list` and `stream` elements may not +carry pending `stream` or `future` values: receiving one fails the invocation, as +does sending one in a `stream` element. Data the peer sends for a sub-stream that the decoded values do not carry — for a `stream` in an `option` that is `none`, say — fails the invocation. diff --git a/js/src/index.d.ts b/js/src/index.d.ts index ab734754f..9307d932f 100644 --- a/js/src/index.d.ts +++ b/js/src/index.d.ts @@ -46,9 +46,7 @@ export type Value = /** * A descriptor recorded by {@link decodeValue} for a pending `stream`/`future` - * value, naming the relative sub-stream `path` and the placeholder to feed, or - * for a `stream` sent `inline` as a single ready chunk whose elements carry - * pending values. + * value, naming the relative sub-stream `path` and the placeholder to feed. */ export type DecodeDeferred = | { @@ -58,8 +56,7 @@ export type DecodeDeferred = resolve: (value: Value) => void; reject: (err: unknown) => void; } - | { path: number[]; kind: "stream"; ty: Type; queue: Chan } - | { path: number[]; kind: "inline"; queue: Chan }; + | { path: number[]; kind: "stream"; ty: Type; queue: Chan }; // ----- types.js ------------------------------------------------------------- @@ -138,8 +135,6 @@ export class Chan implements AsyncIterable { readonly capacity: number; readonly closed: boolean; readonly cancelled: boolean; - /** The number of values handed to the consumer. */ - readonly taken: number; /** * Call `f` once the consumer cancels the channel, or now if it already has. * Hooks are dropped once the consumer has received the end of the channel. @@ -240,8 +235,7 @@ export class Mux { incoming(path: number[]): AsyncReader; /** * Declare the sub-streams of the pending values in the value decoded at - * `path` (the root tuple, a future's value, or a stream's element, in - * order), with paths relative to it. + * `path` (the root tuple, or a future's value), with paths relative to it. */ seal(path: number[], expected: { path: number[]; nested: boolean }[]): void; /** @@ -250,10 +244,8 @@ export class Mux { * fails the invocation. */ finish(path: number[], reader?: AsyncReader): void; - /** Discard the sub-stream at `path` and those nested in its children from index `from` (default 0) on. */ - discard(path: number[], from?: number): void; - /** Call `f` once the sub-stream at `path` is discarded. */ - ondiscard(path: number[], f: () => void): void; + /** Discard the sub-stream at `path`. */ + discard(path: number[]): void; outgoing(path: number[]): Outgoing; abort(err?: unknown): void; closeWrite(): Promise; diff --git a/js/src/io.js b/js/src/io.js index 552eb132a..e47756fac 100644 --- a/js/src/io.js +++ b/js/src/io.js @@ -269,8 +269,6 @@ export class Chan { this.senders = []; this.closed = false; this.cancelled = false; - /** The number of values handed to the consumer. */ - this.taken = 0; /** @type {unknown} */ this.error = undefined; } @@ -297,7 +295,6 @@ export class Chan { this.queue.push(value); return; } - this.taken++; w.resolve({ value, done: false }); } @@ -352,7 +349,6 @@ export class Chan { next() { if (this.queue.length) { const value = /** @type {T} */ (this.queue.shift()); - this.taken++; this.senders.shift()?.(); return Promise.resolve({ value, done: false }); } diff --git a/js/src/mux.js b/js/src/mux.js index 22b9109ea..3b7c09842 100644 --- a/js/src/mux.js +++ b/js/src/mux.js @@ -50,14 +50,9 @@ export const DISCARDED = new Error("sub-stream discarded"); * @property {Chan | undefined} chan the data of the sub-stream at * this path, if one is expected here * @property {boolean} sealed whether every sub-stream nested in it is known - * @property {number} decided the number of stream elements known, whose - * children are all in the trie * @property {boolean} done whether all its own data has been read * @property {boolean} closed whether an empty frame closed it - * @property {Set | undefined} pruned the indices of the children - * forgotten once finished, beyond those of the `decided` stream elements - * @property {number} discardFrom `Infinity`, unless the node is discarded: then - * its own data and that of its children from this index on is dropped + * @property {boolean} discarded whether its data is dropped * @property {Map> | undefined} pending the data * buffered for the paths through each missing child, by path key */ @@ -67,11 +62,9 @@ const node = (sealed) => ({ children: new Map(), chan: undefined, sealed, - decided: 0, done: false, closed: false, - pruned: undefined, - discardFrom: Infinity, + discarded: false, pending: undefined, }); @@ -187,7 +180,9 @@ export class Outgoing { * the root sub-stream `[]`, and those declared with {@link Mux.seal} as each * value carrying pending `stream`/`future` values is decoded, until they are * {@link Mux.finish finished}. Data for a path that may still be declared is - * buffered until it is; data for any other path fails the invocation. + * buffered until it is; data for any other path fails the invocation. Pending + * values nested in `list` or `stream` elements are not supported, so the + * expected sub-streams are bounded by the types of the decoded values. */ export class Mux { /** @param {Transport} transport */ @@ -235,8 +230,6 @@ export class Mux { if (n === 0) { // An empty frame closes its sub-stream, which may already be finished. this._route(path, true)?.close(); - const closed = this._get(path); - if (closed) closed.closed = true; continue; } const chan = this._route(path); @@ -253,42 +246,38 @@ export class Mux { } /** - * The channel of the sub-stream at `path`, `null` if its data is discarded, - * or where the lookup stopped if it is not yet known whether it is expected. - * With `empty`, for an empty frame, a finished sub-stream also gives `null`. + * The node of the expected sub-stream at `path`, or where the lookup + * stopped if it is not yet known whether it is expected. With `empty`, for + * an empty frame, a finished sub-stream is found too. * @param {number[]} path * @param {boolean} [empty] - * @returns {Chan | null | Undecided} + * @returns {Node | Undecided} */ _find(path, empty = false) { let n = this.root; for (const i of path) { - if (i >= n.discardFrom) return null; const child = n.children.get(i); - if (!child) { - const finished = i < n.decided || !!n.pruned?.has(i); - if (empty && finished) return null; - return n.sealed || finished ? unexpected(path) : { at: n, i }; - } + if (!child) return n.sealed ? unexpected(path) : { at: n, i }; n = child; } - if (n.closed) unexpected(path); - if (n.done) return empty ? null : unexpected(path); - if (n.discardFrom !== Infinity) return null; - return n.chan ?? unexpected(path); + if (!n.chan || n.closed || (n.done && !empty)) unexpected(path); + return n; } /** * The channel to route the data of the sub-stream at `path` to, or - * `undefined` to drop it. With `empty`, for an empty frame, the data of a - * path that may still be expected is buffered as if it were finished. + * `undefined` to drop it. With `empty`, for an empty frame, the sub-stream + * is closed, and the data of a path that may still be expected is buffered + * as if it were finished. * @param {number[]} path * @param {boolean} [empty] */ _route(path, empty = false) { const found = this._find(path, empty); - if (found === null) return undefined; - if (found instanceof Chan) return found; + if (!("at" in found)) { + found.closed ||= empty; + return found.done || found.discarded ? undefined : found.chan; + } let pending = found.at.pending?.get(found.i)?.get(key(path)); if (pending?.closed) unexpected(path); if (!pending) attach(found, (pending = { path, chan: new Chan(), empty, closed: false })); @@ -306,7 +295,6 @@ export class Mux { _takePending(path) { let n = this.root; for (const i of path) { - if (i >= n.discardFrom) return undefined; const child = n.children.get(i); if (!child) { const group = n.pending?.get(i); @@ -321,15 +309,14 @@ export class Mux { } /** - * The node at `path`, `undefined` if there is none, or `null` if it is - * discarded by an ancestor. With `create`, missing nodes are created. + * The node at `path`, `undefined` if there is none. With `create`, missing + * nodes are created. * @param {number[]} path * @param {boolean} [create] */ _get(path, create = false) { let n = this.root; for (const i of path) { - if (i >= n.discardFrom) return null; let child = n.children.get(i); if (!child) { if (!create) return undefined; @@ -343,36 +330,23 @@ export class Mux { /** * Declare the sub-streams nested in the value at `path` once it is decoded: - * the root tuple at `[]`, a future's value at its own path, or element `k` - * of a stream at `[...stream, k]`, decoded in order. `expected` lists the - * paths, relative to `path`, of the value's pending `stream`/`future` - * values, and whether each may nest sub-streams of its own. Data for any - * other path nested in the value fails the invocation. + * the root tuple at `[]`, or a future's value at its own path. `expected` + * lists the paths, relative to `path`, of the value's pending + * `stream`/`future` values, and whether each may nest sub-streams of its + * own. Data for any other path nested in the value fails the invocation. * @param {number[]} path * @param {{ path: number[], nested: boolean }[]} expected */ seal(path, expected) { /** @type {Undecided[]} */ const settle = []; - if (path.length) { - const parent = this._get(path.slice(0, -1)); - if (parent && !parent.sealed) { - const from = parent.decided; - parent.decided = Math.max(from, path[path.length - 1] + 1); - for (let i = from; i < parent.decided; i++) settle.push({ at: parent, i }); - } - } const taken = expected.map((e) => this._takePending([...path, ...e.path])); const scope = this._get(path, expected.length > 0); if (scope) scope.sealed = true; expected.forEach((e, j) => { const t = taken[j]; if (t) settle.push(t); - const n = this._get([...path, ...e.path], true); - if (!n) { - t?.pending?.chan.cancel(DISCARDED); - return; - } + const n = /** @type {Node} */ (this._get([...path, ...e.path], true)); n.sealed = !e.nested; if (n.chan) return; n.chan = t?.pending?.chan ?? new Chan(); @@ -386,8 +360,7 @@ export class Mux { /** * Declare that the sub-stream at `path` has no more data: its stream ended, - * or its future's value was decoded. Once the sub-streams nested in it - * finish too, it is forgotten, and any further data for it fails the + * or its future's value was decoded. Any further data for it fails the * invocation, as does any data of it left unread in `reader`. * @param {number[]} path * @param {AsyncReader} [reader] the reader it was read over @@ -402,68 +375,23 @@ export class Mux { n.done = true; n.chan?.close(); for (const i of [...(n.pending?.keys() ?? [])]) this._settle({ at: n, i }); - this._prune(path); } /** - * Forget the node at `path` and its ancestors as long as they are finished, - * without children, and not discarded. + * Discard the sub-stream at `path`: drop its buffered and future data, and + * fail its reader with {@link DISCARDED}. * @param {number[]} path */ - _prune(path) { - const nodes = [this.root]; - for (const i of path) { - const child = nodes[nodes.length - 1].children.get(i); - if (!child) return; - nodes.push(child); - } - for (let d = path.length; d > 0; d--) { - const n = nodes[d]; - if (n.children.size || n.discardFrom !== Infinity || (n.chan && !n.done)) return; - const parent = nodes[d - 1]; - const i = path[d - 1]; - parent.children.delete(i); - if (i >= parent.decided) (parent.pruned ??= new Set()).add(i); - } - } - - /** - * Discard the sub-stream at `path` and those nested in its children from - * index `from` on: drop their buffered and future data, and fail their - * readers with {@link DISCARDED}. - * @param {number[]} path - * @param {number} [from] - */ - discard(path, from = 0) { + discard(path) { const n = this._get(path); - if (!n || (n.done && ![...n.children.keys()].some((i) => i >= from))) return; - n.discardFrom = Math.min(n.discardFrom, from); + if (!n || n.done) return; + n.discarded = true; n.chan?.cancel(DISCARDED); - for (const [i, group] of n.pending ?? []) { - if (i < from) continue; - for (const { chan } of group.values()) chan.cancel(DISCARDED); - n.pending?.delete(i); - } - for (const [i, child] of n.children) { - if (i < from) continue; - eachChan(child, (chan) => chan.cancel(DISCARDED)); - n.children.delete(i); - } - } - - /** - * Call `f` once the sub-stream at `path` is discarded. - * @param {number[]} path @param {() => void} f - */ - ondiscard(path, f) { - const chan = this._get(path)?.chan; - if (chan) chan.onCancel(f); - else f(); } /** * Look up the paths buffered in the group at `at` again: drop or reject - * those decided since, and keep the rest where their lookup stops now. + * those declared since, and keep the rest where their lookup stops now. * @param {Undecided} at */ _settle(at) { @@ -478,8 +406,8 @@ export class Mux { this.abort(err); return; } - if (found === null || found instanceof Chan) pending.chan.cancel(DISCARDED); - else attach(found, pending); + if ("at" in found) attach(found, pending); + else pending.chan.cancel(DISCARDED); } } @@ -492,17 +420,14 @@ export class Mux { } /** - * The reader for the expected sub-stream at `path` (the root is `[]`). The - * reader of a discarded sub-stream fails with {@link DISCARDED}. + * The reader for the expected sub-stream at `path` (the root is `[]`). * @param {number[]} path * @returns {AsyncReader} */ incoming(path) { - const n = this._get(path); - if (n !== null && !n?.chan) throw new Error(`no sub-stream is expected at [${path}]`); - const chan = n?.chan; + const chan = this._get(path)?.chan; + if (!chan) throw new Error(`no sub-stream is expected at [${path}]`); return new AsyncReader(async () => { - if (!chan) throw DISCARDED; const { value, done } = await chan.next(); return done ? null : value; }); diff --git a/js/src/session.js b/js/src/session.js index 71784d69a..8d6b4d087 100644 --- a/js/src/session.js +++ b/js/src/session.js @@ -77,9 +77,6 @@ async function writeFuture(out, ty, value) { /** @param {Outgoing} out @param {Type} elem @param {any} src */ async function writeStream(out, elem, src) { - let base = 0; - /** @type {Promise[]} */ - const tasks = []; for await (const chunk of streamChunks(src, elem)) { const w = new Writer(); if (elem.kind === "u8") { @@ -88,29 +85,21 @@ async function writeStream(out, elem, src) { if (bytes.length === 0) continue; w.varU(bytes.length); w.bytes(bytes); - base += bytes.length; } else { if (!Array.isArray(chunk)) throw new TypeError("expected an array for a `stream` chunk"); if (chunk.length === 0) continue; w.varU(chunk.length); - for (let k = 0; k < chunk.length; k++) { - /** @type {any[]} */ - const sink = []; - encodeValue(w, elem, chunk[k], sink, []); - for (const e of sink) { - const task = writeDeferred(out.index([base + k, ...e.path]), e); - task.catch(() => {}); - tasks.push(task); - } - } - base += chunk.length; + /** @type {any[]} */ + const sink = []; + for (const v of chunk) encodeValue(w, elem, v, sink, []); + if (sink.length) + throw new Error("pending values in `list` or `stream` elements are not supported"); } await out.write(w.finish()); } const end = new Writer(); end.varU(0); // a stream is terminated by an empty chunk await out.write(end.finish()); - await Promise.all(tasks); } // ----- decoding side -------------------------------------------------------- @@ -168,9 +157,7 @@ async function receiveTuple(mux, root, types) { function expect(mux, path, sink) { mux.seal( path, - sink.flatMap((e) => - e.kind === "inline" ? [] : [{ path: e.path, nested: !!e.ty && hasAsync(e.ty) }], - ), + sink.map((e) => ({ path: e.path, nested: e.kind === "future" && !!e.ty && hasAsync(e.ty) })), ); } @@ -194,14 +181,6 @@ function readNested(mux, path, sink, ar) { */ async function readDeferred(mux, path, entry, ar) { switch (entry.kind) { - case "inline": { - // Discard the sub-streams nested in the chunk unless the consumer received it. - const queue = entry.queue; - queue.onCancel(() => { - if (queue.taken === 0) mux.discard(path); - }); - return; - } case "future": return readFuture(mux, path, entry, ar ?? mux.incoming(path)); case "stream": @@ -227,7 +206,7 @@ async function readFuture(mux, path, entry, ar) { const { value, sink } = decoded; expect(mux, path, sink); // A value nested at the future's own path continues reading it. - if (!sink.some((e) => e.kind !== "inline" && e.path.length === 0)) mux.finish(path, ar); + if (!sink.some((e) => e.path.length === 0)) mux.finish(path, ar); const nested = readNested(mux, path, sink, ar); entry.resolve(value); await nested; @@ -235,22 +214,12 @@ async function readFuture(mux, path, entry, ar) { /** * Receive a stream's chunks over `ar` into `queue`, waiting while it is full. - * If the consumer cancels the queue, the rest of the stream is discarded, - * along with the sub-streams nested in the elements it did not receive. + * If the consumer cancels the queue, the rest of the stream is discarded. * @param {Mux} mux @param {number[]} path @param {Type} elem * @param {import("./index.d.ts").Chan} queue @param {AsyncReader} ar */ async function readStream(mux, path, elem, queue, ar) { - const nested = hasAsync(elem); - /** @type {number[]} the index of the first element of each chunk not yet taken */ - const starts = []; - let taken = 0; - let base = 0; - queue.onCancel(() => mux.discard(path, starts[queue.taken - taken] ?? base)); - // Nobody receives the rest of a discarded stream. - mux.ondiscard(path, () => queue.cancel()); - /** @type {Promise[]} */ - const tasks = []; + queue.onCancel(() => mux.discard(path)); try { for (;;) { const len = await ar.decode((r) => atomic(r, readVarU32)); @@ -258,30 +227,8 @@ async function readStream(mux, path, elem, queue, ar) { mux.finish(path, ar); break; } - if (nested) { - starts.splice(0, queue.taken - taken); - taken = queue.taken; - starts.push(base); - } - let chunk; - if (elem.kind === "u8") { - chunk = await ar.take(len); - } else if (!nested) { - chunk = await ar.decode((r) => readChunk(r, elem, len)); - } else { - chunk = []; - for (let k = 0; k < len; k++) { - const { value, sink } = await decodeOne(ar, elem); - chunk.push(value); - const at = [...path, base + k]; - expect(mux, at, sink); - if (!sink.length) continue; - const task = readNested(mux, at, sink); - task.catch(() => {}); - tasks.push(task); - } - } - base += len; + const chunk = + elem.kind === "u8" ? await ar.take(len) : await ar.decode((r) => readChunk(r, elem, len)); if (!(await queue.send(chunk))) break; } } catch (err) { @@ -293,7 +240,6 @@ async function readStream(mux, path, elem, queue, ar) { } } queue.close(); - await Promise.all(tasks); } // ----- public API ----------------------------------------------------------- diff --git a/js/src/value.js b/js/src/value.js index a3d8e51c0..1b096d6ef 100644 --- a/js/src/value.js +++ b/js/src/value.js @@ -135,19 +135,22 @@ const COMPOSITE = new Set([ /** * Decode a single stream/list chunk of `n` elements of type `elem` from `r`. * `stream` chunks are returned as a `Uint8Array`; all others as an array. + * Elements carrying pending `stream`/`future` values are not supported. * @param {Reader} r * @param {Type} elem * @param {number} n - * @param {import("./index.d.ts").DecodeDeferred[]} [sink] - * @param {number[]} [path] * @returns {Generator} */ -export function* readChunk(r, elem, n, sink, path) { +export function* readChunk(r, elem, n) { if (elem.kind === "u8") return yield* atomic(r, (r) => r.take(n).slice()); const out = []; if (COMPOSITE.has(elem.kind)) { + /** @type {import("./index.d.ts").DecodeDeferred[]} */ + const sink = []; for (let i = 0; i < n; i++) { - out.push(yield* decodeSteps(r, elem, sink, childPath(sink, path, i))); + out.push(yield* decodeSteps(r, elem, sink, [])); + if (sink.length) + throw new Error("pending values in `list` or `stream` elements are not supported"); } return out; } @@ -351,8 +354,8 @@ export function encodeValue(w, ty, v, sink, path) { * iterable of chunks for a `stream` — and a descriptor recording the relative * `path` and the placeholder's resolver/queue is appended to `sink`. Without a * `sink`, encountering one throws. A `stream` sent inline as a single ready - * chunk is decoded either way, and recorded in `sink` as `inline` if its - * elements carry pending values. + * chunk is decoded either way. Pending values nested in `list` or `stream` + * elements are not supported. * * @param {Reader} r * @param {Type} ty @@ -378,7 +381,7 @@ export function decodeValue(r, ty, sink, path) { export function* decodeSteps(r, ty, sink, path) { switch (ty.kind) { case "list": - return yield* readChunk(r, ty.elem, yield* atomic(r, readVarU32), sink, path); + return yield* readChunk(r, ty.elem, yield* atomic(r, readVarU32)); case "tuple": { const out = []; for (let i = 0; i < ty.elems.length; i++) { @@ -437,12 +440,10 @@ export function* decodeSteps(r, ty, sink, path) { if (yield* atomic(r, optionStatus)) { // A stream sent inline as a single ready chunk. const n = yield* atomic(r, readVarU32); - const before = sink?.length ?? 0; - const chunk = yield* readChunk(r, ty.elem, n, sink, path); + const chunk = yield* readChunk(r, ty.elem, n); const q = new Chan(); if (n > 0) q.push(chunk); q.close(); - if (sink && sink.length > before) sink.push({ path: path ?? [], kind: "inline", queue: q }); return q; } if (!sink) throw new Error(asyncRequired(ty.kind)); diff --git a/js/test/session.test.js b/js/test/session.test.js index 07c75a83a..4fcaeff3b 100644 --- a/js/test/session.test.js +++ b/js/test/session.test.js @@ -128,26 +128,6 @@ test("aborting after the peer closed fails the sub-streams declared later", asyn await assert.rejects(mux.incoming([0]).u8(), /boom/); }); -test("finished sub-streams are forgotten", { timeout: 1000 }, async () => { - const [client, server] = duplexPair(); - const ty = t.stream(t.option(t.future(t.u8))); - const elems = Array.from({ length: 1000 }, (_, i) => (i % 2 ? i & 0xff : undefined)); - const serving = (async () => { - const inv = await accept(server); - const { params, done } = await inv.receiveParams([ty]); - const futures = []; - for await (const chunk of params[0]) futures.push(...chunk); - const values = await Promise.all(futures); - await done; - assert.equal(inv.mux.root.children.size, 0); - await inv.sendResults([], []); - return values; - })(); - const { done } = await invoke(client, STREAMS, "get", [ty], [[elems]], []); - await done; - assert.deepEqual(await serving, elems); -}); - test("a failed invocation leaves no unhandled rejection", async () => { const transport = { read: () => Promise.reject(new Error("connection refused")), @@ -237,47 +217,6 @@ test("aborting a web stream transport cancels and aborts both sides", async () = ]); }); -test("a failed stream> parameter leaves no unhandled rejection", async () => { - async function* chunks() { - yield [ - () => { - throw new Error("future failed"); - }, - ]; - throw new Error("stream failed"); - } - const transport = { - read: () => new Promise((resolve) => setTimeout(() => resolve(null), 10)), - write: () => {}, - }; - await assert.rejects( - invoke(transport, STREAMS, "echo", [t.stream(t.future(t.string))], [chunks()], [t.u8]), - /stream failed/, - ); -}); - -test( - "a transport failure rejects pending stream> parameters", - { timeout: 1000 }, - async () => { - const frames = []; - const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); - await peer.writeHeader(STREAMS, "echo"); - await peer.outgoing([]).write(Uint8Array.of(0)); - await peer.outgoing([0]).write(Uint8Array.of(1, 0)); - const transport = { - read: async () => frames.shift() ?? Promise.reject(new Error("connection reset")), - write: () => {}, - }; - const inv = await accept(transport); - const { params } = await inv.receiveParams([t.stream(t.future(t.string))]); - const chunks = params[0][Symbol.asyncIterator](); - const { value: chunk } = await chunks.next(); - await assert.rejects(chunk[0], /connection reset/); - await assert.rejects(chunks.next(), /connection reset/); - }, -); - test("frame data is routed as it arrives", async () => { const bytes = [0x01, 0x00, 0x03, 0x0a, 0x14, 0x1e, 0x01, 0x00, 0x02, 0x28]; const transport = { @@ -335,15 +274,17 @@ test("invalid arguments fail before anything is written and abort the transport" assert.equal(aborted.length, 2); }); -test("stream chunks must be bytes", async () => { - const transport = { - read: () => new Promise((resolve) => setTimeout(() => resolve(null), 10)), - write: () => {}, - }; - await assert.rejects( - invoke(transport, STREAMS, "echo", [t.stream(t.u8)], [[1, 2, 3]], [t.u8]), - /expected `Uint8Array`/, - ); +test("invalid stream chunks fail the invocation", async () => { + for (const [ty, chunks, error] of [ + [t.stream(t.u8), [1, 2, 3], /expected `Uint8Array`/], + [t.stream(t.future(t.string)), [["foo"]], /not supported/], + ]) { + const transport = { + read: () => new Promise((resolve) => setTimeout(() => resolve(null), 10)), + write: () => {}, + }; + await assert.rejects(invoke(transport, STREAMS, "echo", [ty], [chunks], [t.u8]), error); + } }); for (const [name, values, error] of [ @@ -409,77 +350,6 @@ test("a cancelled stream result is discarded", { timeout: 1000 }, async () => { assert.deepEqual(await results[0].next(), { value: undefined, done: true }); }); -test( - "cancelling a stream keeps the streams nested in the chunks it delivered", - { timeout: 1000 }, - async () => { - const ty = t.tuple(t.stream(t.stream(t.u8)), t.stream(t.u8)); - const bytes = function* () { - for (let i = 0; i < 1000; i++) yield Uint8Array.of(i & 0xff); - }; - const { result, done, serving } = await echo(ty, [[[bytes(), bytes()], [bytes()]], bytes()]); - const [nested, sibling] = result; - let kept = []; - for await (const chunk of nested) { - kept = chunk; - break; - } - assert.equal(kept.length, 2); - kept[1].cancel(); - // Undiscarded nested data would stop the transport from being read. - const [received, siblings] = await Promise.all([collect(kept[0]), collect(sibling)]); - assert.equal(received.length, 1000); - assert.equal(siblings.length, 1000); - await Promise.all([done, serving]); - }, -); - -test( - "cancelling a received stream discards the streams nested in it", - { timeout: 1000 }, - async () => { - const frames = []; - const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); - await peer.outgoing([]).write(Uint8Array.of(0, 0)); - await peer.outgoing([0]).write(Uint8Array.of(1, 0)); - await peer.outgoing([0]).write(Uint8Array.of(0)); - for (let i = 0; i < 1000; i++) await peer.outgoing([0, 0]).write(Uint8Array.of(1, i & 0xff)); - await peer.outgoing([0, 0]).write(Uint8Array.of(0)); - await peer.outgoing([1]).write(Uint8Array.of(1, 7)); - await peer.outgoing([1]).write(Uint8Array.of(0)); - const transport = { read: async () => frames.shift() ?? null, write: () => {} }; - const ty = [t.stream(t.stream(t.u8)), t.stream(t.u8)]; - const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); - while (!results[0].closed) await tick(); - let hooked = false; - results[0].onCancel(() => (hooked = true)); - results[0].cancel(); - assert.ok(hooked); - assert.deepEqual(await collect(results[1]), [7]); - await done; - }, -); - -test( - "cancelling an inline stream discards the streams nested in it", - { timeout: 1000 }, - async () => { - const frames = []; - const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); - await peer.outgoing([]).write(Uint8Array.of(1, 1, 0, 0)); - for (let i = 0; i < 1000; i++) await peer.outgoing([0, 0]).write(Uint8Array.of(1, i & 0xff)); - await peer.outgoing([0, 0]).write(Uint8Array.of(0)); - await peer.outgoing([1]).write(Uint8Array.of(1, 7)); - await peer.outgoing([1]).write(Uint8Array.of(0)); - const transport = { read: async () => frames.shift() ?? null, write: () => {} }; - const ty = [t.stream(t.stream(t.u8)), t.stream(t.u8)]; - const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); - results[0].cancel(); - assert.deepEqual(await collect(results[1]), [7]); - await done; - }, -); - test("data arriving before its sub-stream is decoded is kept", { timeout: 1000 }, async () => { const frames = []; const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); @@ -511,35 +381,6 @@ test( }, ); -test("element data arriving ahead of a large chunk is kept", { timeout: 5000 }, async () => { - const n = 4000; - const frames = []; - const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); - await peer.outgoing([]).write(Uint8Array.of(0)); - for (let i = 0; i < n; i++) await peer.outgoing([0, i]).write(Uint8Array.of(i & 0xff)); - const w = new Writer(); - w.varU(n); - for (let i = 0; i < n; i++) w.u8(0); - await peer.outgoing([0]).write(w.finish()); - await peer.outgoing([0]).write(Uint8Array.of(0)); - const transport = { read: async () => frames.shift() ?? null, write: () => {} }; - const { results, done } = await invoke( - transport, - STREAMS, - "get", - [], - [], - [t.stream(t.future(t.u8))], - ); - const out = []; - for await (const chunk of results[0]) out.push(...(await Promise.all(chunk))); - assert.deepEqual( - out, - Array.from({ length: n }, (_, i) => i & 0xff), - ); - await done; -}); - test( "more data than a sub-stream buffers arriving ahead of its declaration is kept", { timeout: 1000 }, @@ -550,98 +391,35 @@ test( await peer.outgoing([]).write(Uint8Array.of(0)); for (let i = 0; i < n; i++) await peer.outgoing([0, 0]).write(Uint8Array.of(1, i & 0xff)); await peer.outgoing([0, 0]).write(Uint8Array.of(0)); - await peer.outgoing([0]).write(Uint8Array.of(1, 0)); await peer.outgoing([0]).write(Uint8Array.of(0)); const transport = { read: async () => frames.shift() ?? null, write: () => {} }; - const { results, done } = await invoke( - transport, - STREAMS, - "get", - [], - [], - [t.stream(t.stream(t.u8))], - ); - const inner = []; - for await (const chunk of results[0]) inner.push(...chunk); - assert.equal(inner.length, 1); + const ty = [t.future(t.tuple(t.stream(t.u8)))]; + const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); + const [inner] = await results[0]; assert.deepEqual( - await collect(inner[0]), + await collect(inner), Array.from({ length: n }, (_, i) => i & 0xff), ); await done; }, ); - -test("data for a sub-stream the reply does not carry fails it", { timeout: 1000 }, async () => { - const frames = []; - const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); - await peer.outgoing([]).write(Uint8Array.of(0, 0)); - for (let i = 0; i < 100; i++) await peer.outgoing([0]).write(Uint8Array.of(1, i)); - await peer.outgoing([1]).write(Uint8Array.of(1, 7)); - await peer.outgoing([1]).write(Uint8Array.of(0)); - let aborted; - const transport = { - read: async () => frames.shift() ?? null, - write: () => {}, - abort: (reason) => void (aborted = reason), - }; - const ty = [t.option(t.stream(t.u8)), t.stream(t.u8)]; - const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); - assert.equal(results[0], undefined); - await assert.rejects(collect(results[1]), /unexpected data for sub-stream \[0\]/); - await assert.rejects(done, /unexpected data for sub-stream \[0\]/); - assert.match(aborted.message, /unexpected data/); -}); - -test( - "an empty frame for a sub-stream the reply does not carry fails it", - { timeout: 1000 }, - async () => { +test("an invocation sends no frames for empty data", async () => { + for (const [types, args, expected] of [ + [[], [], "\x00\x03foo\x03bar"], + [[t.future()], [Promise.resolve()], "\x00\x03foo\x03bar\x00\x01\x00\x01\x00\x00"], + ]) { const frames = []; - const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); - await peer.outgoing([]).write(Uint8Array.of(0, 0)); - await peer.outgoing([0]).close(); - await peer.outgoing([1]).write(Uint8Array.of(1, 7)); - await peer.outgoing([1]).write(Uint8Array.of(0)); - const transport = { read: async () => frames.shift() ?? null, write: () => {} }; - const ty = [t.option(t.stream(t.u8)), t.stream(t.u8)]; - const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); - await assert.rejects(collect(results[1]), /unexpected data for sub-stream \[0\]/); - await assert.rejects(done, /unexpected data for sub-stream \[0\]/); - }, -); - -test("empty writes send no frames", async () => { - const frames = []; - const mux = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); - await mux.outgoing([]).write(new Uint8Array(0)); - await mux.outgoing([0]).write(new Uint8Array(0)); - assert.deepEqual(frames, []); -}); - -test("an invocation with empty parameters sends just the header", async () => { - const frames = []; - let closed = false; - const transport = { - read: async () => null, - write: (bytes) => void frames.push(bytes), - closeWrite: () => void (closed = true), - }; - const { done } = await invoke(transport, "foo", "bar", [], [], []); - await done; - assert.ok(closed); - assert.deepEqual(Buffer.concat(frames), Buffer.from("\x00\x03foo\x03bar", "latin1")); -}); - -test("a future without a payload closes its sub-stream", async () => { - const frames = []; - const transport = { read: async () => null, write: (bytes) => void frames.push(bytes) }; - const { done } = await invoke(transport, "foo", "bar", [t.future()], [Promise.resolve()], []); - await done; - assert.deepEqual( - Buffer.concat(frames), - Buffer.from("\x00\x03foo\x03bar\x00\x01\x00\x01\x00\x00", "latin1"), - ); + let closed = false; + const transport = { + read: async () => null, + write: (bytes) => void frames.push(bytes), + closeWrite: () => void (closed = true), + }; + const { done } = await invoke(transport, "foo", "bar", types, args, []); + await done; + assert.ok(closed); + assert.deepEqual(Buffer.concat(frames), Buffer.from(expected, "latin1")); + } }); /** A transport fed by the frames a peer writes, until `inbound` is closed. */ @@ -659,43 +437,21 @@ function feed() { return { inbound, peer, transport, aborted }; } -test( - "a future without a payload resolves once its sub-stream is closed", - { timeout: 1000 }, - async () => { +test("a future without a payload resolves at the end of the transport", async () => { + for (const data of [false, true]) { const { inbound, peer, transport } = feed(); await peer.outgoing([]).write(Uint8Array.of(0)); - const ty = [t.future()]; - const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); - let resolved = false; - results[0].then(() => (resolved = true)); - await tick(); - await tick(); - assert.equal(resolved, false); - await peer.outgoing([0]).close(); - assert.equal(await results[0], undefined); + if (data) await peer.outgoing([0]).write(Uint8Array.of(0)); inbound.close(); - await done; - }, -); - -test("a future without a payload resolves once the transport ends", { timeout: 1000 }, async () => { - const { inbound, peer, transport } = feed(); - await peer.outgoing([]).write(Uint8Array.of(0)); - inbound.close(); - const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.future()]); - assert.equal(await results[0], undefined); - await done; -}); - -test("a future without a payload fails if data arrives on it", { timeout: 1000 }, async () => { - const { inbound, peer, transport } = feed(); - await peer.outgoing([]).write(Uint8Array.of(0)); - await peer.outgoing([0]).write(Uint8Array.of(0)); - inbound.close(); - const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.future()]); - await assert.rejects(results[0], /unexpected data for sub-stream \[0\]/); - await assert.rejects(done, /unexpected data for sub-stream \[0\]/); + const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.future()]); + if (data) { + await assert.rejects(results[0], /unexpected data for sub-stream \[0\]/); + await assert.rejects(done, /unexpected data for sub-stream \[0\]/); + } else { + assert.equal(await results[0], undefined); + await done; + } + } }); test("an empty frame for a finished sub-stream is accepted", { timeout: 1000 }, async () => { @@ -718,92 +474,22 @@ test("an empty frame for a finished sub-stream is accepted", { timeout: 1000 }, }); test( - "a second empty frame for a sub-stream not yet declared fails it", - { timeout: 1000 }, - async () => { - const { inbound, peer, transport, aborted } = feed(); - await peer.outgoing([]).write(Uint8Array.of(0)); - await peer.outgoing([0, 0]).close(); - await peer.outgoing([0, 0]).close(); - await peer.outgoing([0]).write(Uint8Array.of(1, 1, 0)); - await peer.outgoing([0]).write(Uint8Array.of(0)); - inbound.close(); - const ty = [t.stream(t.option(t.future()))]; - await invoke(transport, STREAMS, "get", [], [], ty); - assert.match(String(await aborted), /unexpected data for sub-stream \[0,0\]/); - }, -); - -test( - "data after an empty frame for a sub-stream not yet declared fails it", - { timeout: 1000 }, - async () => { - const { inbound, peer, transport, aborted } = feed(); - await peer.outgoing([]).write(Uint8Array.of(0)); - await peer.outgoing([0, 0]).close(); - await peer.outgoing([0, 0]).write(Uint8Array.of(7)); - await peer.outgoing([0]).write(Uint8Array.of(1, 1, 0)); - await peer.outgoing([0]).write(Uint8Array.of(0)); - inbound.close(); - const ty = [t.stream(t.option(t.future(t.u8)))]; - await invoke(transport, STREAMS, "get", [], [], ty); - assert.match(String(await aborted), /unexpected data for sub-stream \[0,0\]/); - }, -); - -test( - "an empty frame for a sub-stream the reply does not carry fails it after a sibling's", - { timeout: 1000 }, - async () => { - const { inbound, peer, transport, aborted } = feed(); - await peer.outgoing([]).write(Uint8Array.of(0, 0)); - await peer.outgoing([1]).write(Uint8Array.of(7)); - const ty = [t.option(t.stream(t.u8)), t.future(t.u8)]; - const { results } = await invoke(transport, STREAMS, "get", [], [], ty); - assert.equal(await results[1], 7); - await peer.outgoing([0]).close(); - inbound.close(); - assert.match(String(await aborted), /unexpected data for sub-stream \[0\]/); - }, -); - -test( - "an empty frame arriving before its sub-stream finishes is accepted", + "an empty frame arriving before its sub-stream is declared closes it", { timeout: 1000 }, async () => { const frames = []; const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); await peer.outgoing([]).write(Uint8Array.of(0)); await peer.outgoing([0, 0]).close(); - await peer.outgoing([0]).write(Uint8Array.of(1, 0)); await peer.outgoing([0]).write(Uint8Array.of(0)); const transport = { read: async () => frames.shift() ?? null, write: () => {} }; - const ty = [t.stream(t.option(t.future(t.u8)))]; + const ty = [t.future(t.tuple(t.future()))]; const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); - assert.deepEqual(await collect(results[0]), [undefined]); + const [inner] = await results[0]; + assert.equal(await inner, undefined); await done; }, ); - -test( - "data for a stream after its end fails it, even once cancelled", - { timeout: 1000 }, - async () => { - const { inbound, peer, transport, aborted } = feed(); - await peer.outgoing([]).write(Uint8Array.of(0)); - await peer.outgoing([0]).write(Uint8Array.of(1, 0)); - await peer.outgoing([0]).write(Uint8Array.of(0)); - const ty = [t.stream(t.future(t.u8))]; - const { results } = await invoke(transport, STREAMS, "get", [], [], ty); - while (!results[0].closed) await tick(); - results[0].cancel(); - await peer.outgoing([0, 0]).write(Uint8Array.of(5)); - await peer.outgoing([0]).write(Uint8Array.of(1, 0)); - inbound.close(); - assert.match(String(await aborted), /unexpected data for sub-stream \[0\]/); - }, -); - test("frame data beyond the end of a sub-stream fails it", { timeout: 1000 }, async () => { for (const [ty, value, extra, split] of [ [t.future(t.u8), [5], 40, true], @@ -839,17 +525,18 @@ test( const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); await peer.outgoing([]).write(Uint8Array.of(0)); await peer.outgoing([0, 0]).write(Uint8Array.of(1, 0)); - await peer.outgoing([0]).write(Uint8Array.of(1, 0)); + await peer.outgoing([0]).write(Uint8Array.of(0)); for (let i = 0; i < 1000; i++) await peer.outgoing([0, 0]).write(Uint8Array.of(1, i & 0xff)); let reads = 0; const transport = { read: async () => (reads++, frames.shift() ?? null), write: () => {} }; - const { results } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.stream(t.u8))]); - await results[0][Symbol.asyncIterator]().next(); + const ty = [t.future(t.tuple(t.stream(t.u8)))]; + const { results } = await invoke(transport, STREAMS, "get", [], [], ty); + const [inner] = await results[0]; + await inner[Symbol.asyncIterator]().next(); await new Promise((resolve) => setTimeout(resolve, 50)); assert.ok(reads < 500, `read ${reads} frames`); }, ); - test("data for the root after the reply fails it", { timeout: 1000 }, async () => { const frames = []; const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); @@ -863,37 +550,24 @@ test("data for the root after the reply fails it", { timeout: 1000 }, async () = await assert.rejects(done, /unexpected data for sub-stream \[\]/); }); -test("cancelling an inline stream discards the sub-streams nested in it", () => { - const mux = new Mux({ read: () => new Promise(() => {}), write: () => {} }); - mux.seal([], [{ path: [0], nested: true }]); - mux.seal([0], [{ path: [0], nested: false }]); - mux.finish([0]); - const nested = mux.incoming([0, 0]); - mux.discard([0]); - return assert.rejects(nested.u8(), /sub-stream discarded/); -}); - -test("a stream truncated mid-frame fails", { timeout: 1000 }, async () => { - const frames = []; - const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); - await peer.outgoing([]).write(Uint8Array.of(0)); - frames.push(Uint8Array.of(1, 0, 5, 1, 6)); - const transport = { read: async () => frames.shift() ?? null, write: () => {} }; - const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); - await assert.rejects(collect(results[0]), /unexpected end of input/); - await assert.rejects(done, /unexpected end of input/); -}); - -test("a stream truncated mid-length fails", { timeout: 1000 }, async () => { - const frames = []; - const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); - await peer.outgoing([]).write(Uint8Array.of(0)); - await peer.outgoing([0]).write(Uint8Array.of(1, 5)); - await peer.outgoing([0]).write(Uint8Array.of(0x80)); - const transport = { read: async () => frames.shift() ?? null, write: () => {} }; - const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); - await assert.rejects(collect(results[0]), /unexpected end of input/); - await assert.rejects(done, /unexpected end of input/); +test("a truncated stream fails", { timeout: 1000 }, async () => { + for (const send of [ + (peer, frames) => frames.push(Uint8Array.of(1, 0, 5, 1, 6)), + (peer) => peer.outgoing([0]).write(Uint8Array.of(1, 5, 0x80)), + async (peer) => { + await peer.outgoing([0]).write(Uint8Array.of(1, 7)); + await peer.outgoing([0]).close(); + }, + ]) { + const frames = []; + const peer = new Mux({ read: async () => null, write: (bytes) => void frames.push(bytes) }); + await peer.outgoing([]).write(Uint8Array.of(0)); + await send(peer, frames); + const transport = { read: async () => frames.shift() ?? null, write: () => {} }; + const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); + await assert.rejects(collect(results[0]), /unexpected end of input/); + await assert.rejects(done, /unexpected end of input/); + } }); const futures = "wrpc-test:async/futures"; @@ -977,63 +651,77 @@ test("future> resolves to a byte stream", async () => { await Promise.all([done, serving]); }); -test("stream> yields chunks of promises", async () => { - const ty = t.stream(t.future(t.string)); - const { result, done, serving } = await echo(ty, [["a", "b"], ["c"]]); - const out = []; - for await (const chunk of result) out.push(...(await Promise.all(chunk))); - assert.deepEqual(out, ["a", "b", "c"]); - await Promise.all([done, serving]); -}); - -test("stream> yields chunks of byte streams", async () => { - const ty = t.stream(t.stream(t.u8)); - // one outer chunk carrying two inner byte streams - const { result, done, serving } = await echo(ty, [ - [new Uint8Array([1, 2]), new Uint8Array([3, 4])], - ]); - const out = []; - for await (const chunk of result) { - for (const inner of chunk) out.push(await collect(inner)); - } - assert.deepEqual(out, [ - [1, 2], - [3, 4], - ]); - await Promise.all([done, serving]); -}); - -test("an empty frame closes its sub-stream", { timeout: 1000 }, async () => { - const { peer, transport } = feed(); - await peer.outgoing([]).write(Uint8Array.of(0)); - await peer.outgoing([0]).write(Uint8Array.of(1, 7, 0)); - await peer.outgoing([0]).close(); - const { results } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); - assert.deepEqual(await collect(results[0]), [7]); -}); +for (const [kind, ty, chunk] of [ + ["list", t.list(t.future(t.u8)), Uint8Array.of(1, 0)], + ["stream", t.stream(t.future(t.u8)), Uint8Array.of(0)], +]) { + test(`pending values in ${kind} elements are not supported`, { timeout: 1000 }, async () => { + const { inbound, peer, transport, aborted } = feed(); + await peer.outgoing([]).write(chunk); + await peer.outgoing([0]).write(Uint8Array.of(1, 0)); + inbound.close(); + const invoked = invoke(transport, STREAMS, "get", [], [], [ty]); + if (kind === "stream") + await assert.rejects(collect((await invoked).results[0]), /not supported/); + else await assert.rejects(invoked, /not supported/); + assert.match(String(await aborted), /not supported/); + }); +} -test("a stream closed before its terminator fails", { timeout: 1000 }, async () => { - const { peer, transport, aborted } = feed(); +test("ready values in stream elements are received", { timeout: 1000 }, async () => { + const { inbound, peer, transport } = feed(); await peer.outgoing([]).write(Uint8Array.of(0)); - await peer.outgoing([0]).write(Uint8Array.of(1, 7)); - await peer.outgoing([0]).close(); - const { results } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); - await assert.rejects(collect(results[0]), /unexpected end of input/); - assert.match(String(await aborted), /unexpected end of input/); + await peer.outgoing([0]).write(Uint8Array.of(2, 1, 5, 1, 6)); + await peer.outgoing([0]).write(Uint8Array.of(0)); + inbound.close(); + const ty = [t.stream(t.future(t.u8))]; + const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); + assert.deepEqual(await Promise.all(await collect(results[0])), [5, 6]); + await done; }); for (const [name, frame] of [ - ["data", (out) => out.write(Uint8Array.of(0))], + ["data", (out) => out.write(Uint8Array.of(7))], ["an empty frame", (out) => out.close()], ]) { - test(`${name} for a closed sub-stream fails it`, { timeout: 1000 }, async () => { - const { inbound, peer, transport, aborted } = feed(); - await peer.outgoing([]).write(Uint8Array.of(0)); - await peer.outgoing([0]).write(Uint8Array.of(1, 7)); - await peer.outgoing([0]).close(); - await frame(peer.outgoing([0])); - inbound.close(); - await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); - assert.match(String(await aborted), /unexpected data for sub-stream \[0\]/); - }); + test( + `${name} for a sub-stream the reply does not carry fails it`, + { timeout: 1000 }, + async () => { + const { inbound, peer, transport, aborted } = feed(); + await peer.outgoing([]).write(Uint8Array.of(0, 0)); + await frame(peer.outgoing([0])); + await peer.outgoing([1]).write(Uint8Array.of(1, 7, 0)); + inbound.close(); + const ty = [t.option(t.stream(t.u8)), t.stream(t.u8)]; + const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); + await assert.rejects(collect(results[1]), /unexpected data for sub-stream \[0\]/); + await assert.rejects(done, /unexpected data for sub-stream \[0\]/); + assert.match(String(await aborted), /unexpected data/); + }, + ); + + for (const [declared, path, ty, init] of [ + [true, [0], t.stream(t.u8), [[[0], Uint8Array.of(1, 7, 0)]]], + [false, [0, 0], t.future(t.tuple(t.future(t.u8))), []], + ]) { + test( + `${name} for a closed sub-stream ${declared ? "" : "not yet declared "}fails it`, + { timeout: 1000 }, + async () => { + const { inbound, peer, transport, aborted } = feed(); + await peer.outgoing([]).write(Uint8Array.of(0)); + for (const [p, bytes] of init) await peer.outgoing(p).write(bytes); + await peer.outgoing(path).close(); + await frame(peer.outgoing(path)); + if (!declared) await peer.outgoing([0]).write(Uint8Array.of(0)); + inbound.close(); + await invoke(transport, STREAMS, "get", [], [], [ty]); + assert.match( + String(await aborted), + new RegExp(`unexpected data for sub-stream \\[${path}\\]`), + ); + }, + ); + } } From e160c28095ddd6a9796699f4acd5fb4999d05247 Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Fri, 23 Oct 2026 19:46:08 +0200 Subject: [PATCH 09/10] chore(js): bump version to 0.3.0 `Outgoing.write` no longer sends an empty frame for empty data, which is a breaking change. Assisted-by: claude:claude-opus-5-5 --- js/package-lock.json | 4 ++-- js/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/js/package-lock.json b/js/package-lock.json index 2179cd4b8..51acb0f18 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bytecodealliance/wrpc", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bytecodealliance/wrpc", - "version": "0.2.1", + "version": "0.3.0", "license": "Apache-2.0 WITH LLVM-exception", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/js/package.json b/js/package.json index faa0df32f..a2267df87 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "@bytecodealliance/wrpc", - "version": "0.2.1", + "version": "0.3.0", "description": "Transport-agnostic, component-native wRPC client and server for JavaScript", "type": "module", "license": "Apache-2.0 WITH LLVM-exception", From 43770eb92fec4bb4d7e71cc7fa798ea017623612 Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Mon, 26 Oct 2026 18:04:00 +0100 Subject: [PATCH 10/10] fix(js)!: do not fail received futures and streams A decoded value must not report whether receiving it failed, as in the Rust implementation: if receiving a pending `future` fails, its promise never settles, and if receiving a pending `stream` fails, it ends. The failure is reported by `done` and aborts the transport, like before. Assisted-by: claude:claude-opus-5-5 --- js/README.md | 4 ++++ js/src/index.d.ts | 1 - js/src/session.js | 11 +++-------- js/src/value.js | 11 ++--------- js/test/session.test.js | 17 ++++++++++------- 5 files changed, 19 insertions(+), 25 deletions(-) diff --git a/js/README.md b/js/README.md index 6bcb16899..354dad07d 100644 --- a/js/README.md +++ b/js/README.md @@ -108,6 +108,10 @@ does sending one in a `stream` element. Data the peer sends for a sub-stream that the decoded values do not carry — for a `stream` in an `option` that is `none`, say — fails the invocation. +Received values never fail: if receiving a `future` fails, its promise never +settles, and if receiving a `stream` fails, it ends. The failure is reported by +`done`, as in the Rust implementation. + ### Serving `accept` is the server-side counterpart: read an invocation's header off a diff --git a/js/src/index.d.ts b/js/src/index.d.ts index 9307d932f..6b45b22a2 100644 --- a/js/src/index.d.ts +++ b/js/src/index.d.ts @@ -54,7 +54,6 @@ export type DecodeDeferred = kind: "future"; ty: Type | null; resolve: (value: Value) => void; - reject: (err: unknown) => void; } | { path: number[]; kind: "stream"; ty: Type; queue: Chan }; diff --git a/js/src/session.js b/js/src/session.js index 8d6b4d087..e8749069f 100644 --- a/js/src/session.js +++ b/js/src/session.js @@ -199,7 +199,6 @@ async function readFuture(mux, path, entry, ar) { else if (await ar.fill(1)) throw unexpectedError(path); else decoded = { value: undefined, sink: [] }; } catch (err) { - entry.reject(err); if (err === DISCARDED) return; throw err; } @@ -232,14 +231,10 @@ async function readStream(mux, path, elem, queue, ar) { if (!(await queue.send(chunk))) break; } } catch (err) { - if (!queue.cancelled && err !== DISCARDED) { - // Surface a transport/decode failure to whoever is consuming the stream, - // rather than leaving the consumer hanging on a queue that never closes. - queue.close(err); - throw err; - } + if (!queue.cancelled && err !== DISCARDED) throw err; + } finally { + queue.close(); } - queue.close(); } // ----- public API ----------------------------------------------------------- diff --git a/js/src/value.js b/js/src/value.js index 1b096d6ef..958554c23 100644 --- a/js/src/value.js +++ b/js/src/value.js @@ -425,15 +425,8 @@ export function* decodeSteps(r, ty, sink, path) { if (!sink) throw new Error(asyncRequired(ty.kind)); /** @type {(value: any) => void} */ let resolve = () => {}; - /** @type {(err: unknown) => void} */ - let reject = () => {}; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - // The failure also surfaces through `done`, so an ignored future must not be unhandled. - promise.catch(() => {}); - sink.push({ path: path ?? [], kind: "future", ty: ty.some, resolve, reject }); + const promise = new Promise((res) => (resolve = res)); + sink.push({ path: path ?? [], kind: "future", ty: ty.some, resolve }); return promise; } case "stream": { diff --git a/js/test/session.test.js b/js/test/session.test.js index 4fcaeff3b..8ccdab0f2 100644 --- a/js/test/session.test.js +++ b/js/test/session.test.js @@ -445,8 +445,9 @@ test("a future without a payload resolves at the end of the transport", async () inbound.close(); const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.future()]); if (data) { - await assert.rejects(results[0], /unexpected data for sub-stream \[0\]/); await assert.rejects(done, /unexpected data for sub-stream \[0\]/); + const pending = Symbol("pending"); + assert.equal(await Promise.race([results[0], pending]), pending); } else { assert.equal(await results[0], undefined); await done; @@ -546,7 +547,7 @@ test("data for the root after the reply fails it", { timeout: 1000 }, async () = await peer.outgoing([0]).write(Uint8Array.of(0)); const transport = { read: async () => frames.shift() ?? null, write: () => {} }; const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); - await assert.rejects(collect(results[0]), /unexpected data for sub-stream \[\]/); + await collect(results[0]); await assert.rejects(done, /unexpected data for sub-stream \[\]/); }); @@ -565,7 +566,7 @@ test("a truncated stream fails", { timeout: 1000 }, async () => { await send(peer, frames); const transport = { read: async () => frames.shift() ?? null, write: () => {} }; const { results, done } = await invoke(transport, STREAMS, "get", [], [], [t.stream(t.u8)]); - await assert.rejects(collect(results[0]), /unexpected end of input/); + await collect(results[0]); await assert.rejects(done, /unexpected end of input/); } }); @@ -661,9 +662,11 @@ for (const [kind, ty, chunk] of [ await peer.outgoing([0]).write(Uint8Array.of(1, 0)); inbound.close(); const invoked = invoke(transport, STREAMS, "get", [], [], [ty]); - if (kind === "stream") - await assert.rejects(collect((await invoked).results[0]), /not supported/); - else await assert.rejects(invoked, /not supported/); + if (kind === "stream") { + const { results, done } = await invoked; + await collect(results[0]); + await assert.rejects(done, /not supported/); + } else await assert.rejects(invoked, /not supported/); assert.match(String(await aborted), /not supported/); }); } @@ -695,7 +698,7 @@ for (const [name, frame] of [ inbound.close(); const ty = [t.option(t.stream(t.u8)), t.stream(t.u8)]; const { results, done } = await invoke(transport, STREAMS, "get", [], [], ty); - await assert.rejects(collect(results[1]), /unexpected data for sub-stream \[0\]/); + await collect(results[1]); await assert.rejects(done, /unexpected data for sub-stream \[0\]/); assert.match(String(await aborted), /unexpected data/); },