fix: credit flow control and auto-resume for lost remote attachments (#287)
* fix(daemonmux): credit flow control instead of resetting streams on a full queue A remote attachment died with attachment_lost when the daemon produced output faster than the QUIC path drained it: every stream had a fixed 8-frame outbound queue, and the 9th frame reset the stream. Each stream now has a byte window derived from the negotiated ceilings (512 KiB by default). Writers spend credit per Data frame and wait for a MuxWindowUpdate when it runs out; receivers return credit in batches as their consumer drains. A slow link or consumer now slows the stream instead of killing it. Receiving past the granted window is a peer protocol violation and still resets that stream only. Bumps protocol.Version to 60. * feat(observability): log stream resets, pool retirements, and carry loss causes to the client - daemonmux logs every stream reset (side, stream, local or peer initiator, failure kind, cause) and every physical connection failure. - The broker pool records why each pooled connection retires (idle, evicted, abandoned, merged, lost) as the entry's cancellation cause, logs it with the number of streams still using it, and returns it on the affected streams. - brokeripc keeps a physical loss's failure kind and a bounded, display-safe cause on the wire, so the client log no longer shows cause=null. - The daemon logs the read error that ended a client connection. * fix(client): resume a lost attachment on the same session instead of dropping out When the stream carrying a live attachment is lost while the broker stays up, the client now reconnects to the exact session and tab it was showing, with the supervisor backoff and a Connecting presentation. The daemon keeps the session parked for resume, so the user lands back where they were. After five failed resumes it returns to the picker with the destination-failure notice. * docs(protocol): describe daemonmux per-stream credit flow control * fix(daemonmux): harden credit flow control against settled streams and lost grants - wake credit waiters when the peer closes a stream with unread data, and refuse sends on a closing stream - route WindowUpdate through the outbound overflow isolation so a lost grant resets the stream - Send refuses Data; stream data only goes through SendData under credit - negotiate a stream ceiling whose full windows fit the aggregate budget - correct credit-unit wording in the proto and message docs; drop an unused scheduler parameter * fix(client): resume only transport losses and report exhaustion once - reset the resume budget after every successful reattach - retry only transport-class failures; refusals, ended sessions and ambiguous outcomes are final - keep Connecting during retries and leave the attached presentation with one notice on exhaustion - stop resuming when the broker connection drops during backoff - pool: drop duplicate loss mapping and retire failed dials with their own error - brokeripc: document that stream loss causes travel as display text - daemon: log lost client connections with the session, skipping self-closed transports * fix(client): give up on a flapping resumed session and report blocked resumes - restore the resume budget only after a resumed attachment stays up for 30s - report a resume whose reattach cannot claim the terminal instead of dropping silently - daemonmux: Send also refuses pointer-form Data * fix(darwin): verify same-user peers with LOCAL_PEERCRED and accept root-owned system symlinks The private mux carriage refused every macOS peer because only Linux had a peer credential check, and broker path validation rejected macOS /var and /tmp, which are root-owned symlinks. Tests that bound sockets under the long macOS TMPDIR now use short /tmp directories. * fix(ci): stop resize test detaching its client and treat carriage close as orderly broker shutdown TestResizePreservesLiveContentAndEvictsScrollback built a session without a lifecycle ID, so the first paint failed validation, which the daemon handles as a send error and detaches the attachment in the background. Under load that detach froze the attachment before the second resize. A broker session whose write is interrupted by the listener's own carriage close reported streamframe.ErrClosed, which shutdown treated as a failure. It is now an orderly disconnect like net.ErrClosed. * fix(daemonmux): stop reading a live stream as EOF when a frame lands between Take and the watch check A live stream's watch channel is closed by signalWatch whenever a frame arrives, so muxStreamPipe.Read treated a frame applied after Take as a settled stream and returned EOF mid-stream. Only the shared closed channel Watch hands out for a terminal or unknown stream now means EOF. * fix(brokeripc): treat a broken pipe as an orderly peer departure A peer that disconnects while the broker writes surfaces EPIPE, the write side twin of ECONNRESET, and broker shutdown reported it as a listener failure. Registry tests also wait 5s instead of 1s for a probe or snapshot so a loaded race runner does not time them out.
- commit
- 6253aa4956b61056ba4418d2f7b13b9993725486
- author
- bnema · · 2026-09-25 19:44 UTC
- committer
- GitHub · · 2026-09-25 19:44 UTC
- parent
- 51fda06
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
47 files · +1508 -396
M docs/protocol.md +10 -0
| @@ -119,6 +119,16 @@ and bounded per-connection queues. The remote helpers `_broker-mux-stdio`, | |||
| 119 | 119 | `_broker-mux-quic-bootstrap`, and `_broker-mux-quic-proxy` bridge a remote | |
| 120 | 120 | daemonmux socket to stdio or to one freshly authenticated QUIC stream. | |
| 121 | 121 | ||
| 122 | + | Every daemonmux logical stream is credit flow controlled in both directions. | |
| 123 | + | Each side starts with the same per-stream byte window, derived from the | |
| 124 | + | negotiated ceilings (aggregate bytes / max streams, 512 KiB by default). A | |
| 125 | + | `MuxData` frame costs its payload plus 64 bytes of credit; the writer waits | |
| 126 | + | when credit runs out, and the receiver returns consumed credit with | |
| 127 | + | `MuxWindowUpdate` once half a window is drained. A slow link or a slow | |
| 128 | + | consumer therefore slows its own stream without affecting siblings. Data | |
| 129 | + | beyond the granted window, or credit beyond the window, is a peer violation | |
| 130 | + | and resets only that stream. | |
| 131 | + | ||
| 122 | 132 | Setup is fully bounded: the dialer's one context/deadline covers the Unix | |
| 123 | 133 | dial, preamble, Register send, and wait for Registered, and the server requires Register within | |
| 124 | 134 | the same accept-time handshake budget that bounded the preamble and admission. | |
| @@ -119,6 +119,16 @@ and bounded per-connection queues. The remote helpers `_broker-mux-stdio`, | |||||
| 119 | `_broker-mux-quic-bootstrap`, and `_broker-mux-quic-proxy` bridge a remote | 119 | `_broker-mux-quic-bootstrap`, and `_broker-mux-quic-proxy` bridge a remote | ||
| 120 | daemonmux socket to stdio or to one freshly authenticated QUIC stream. | 120 | daemonmux socket to stdio or to one freshly authenticated QUIC stream. | ||
| 121 | 121 | ||||
| 122 | + | Every daemonmux logical stream is credit flow controlled in both directions. | |||
| 123 | + | Each side starts with the same per-stream byte window, derived from the | |||
| 124 | + | negotiated ceilings (aggregate bytes / max streams, 512 KiB by default). A | |||
| 125 | + | `MuxData` frame costs its payload plus 64 bytes of credit; the writer waits | |||
| 126 | + | when credit runs out, and the receiver returns consumed credit with | |||
| 127 | + | `MuxWindowUpdate` once half a window is drained. A slow link or a slow | |||
| 128 | + | consumer therefore slows its own stream without affecting siblings. Data | |||
| 129 | + | beyond the granted window, or credit beyond the window, is a peer violation | |||
| 130 | + | and resets only that stream. | |||
| 131 | + | ||||
| 122 | Setup is fully bounded: the dialer's one context/deadline covers the Unix | 132 | Setup is fully bounded: the dialer's one context/deadline covers the Unix | ||
| 123 | dial, preamble, Register send, and wait for Registered, and the server requires Register within | 133 | dial, preamble, Register send, and wait for Registered, and the server requires Register within | ||
| 124 | the same accept-time handshake budget that bounded the preamble and admission. | 134 | the same accept-time handshake budget that bounded the preamble and admission. | ||
M internal/adapters/brokerconfig/config.go +11 -3
| @@ -139,8 +139,11 @@ func (l Layout) VerifyCreated() error { | |||
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | 141 | // rejectSymlinkedComponents refuses a path any of whose existing components is | |
| 142 | - | // a symlink. A component that does not exist yet ends the walk: the caller | |
| 143 | - | // creates the remaining private directories itself. | |
| 142 | + | // a symlink not owned by root. A component that does not exist yet ends the | |
| 143 | + | // walk: the caller creates the remaining private directories itself. | |
| 144 | + | // | |
| 145 | + | // Root-owned symlinks are system layout, such as macOS /var -> private/var and | |
| 146 | + | // /tmp -> private/tmp; no other user can plant or retarget them. | |
| 144 | 147 | func rejectSymlinkedComponents(abs string) error { | |
| 145 | 148 | volume := filepath.VolumeName(abs) | |
| 146 | 149 | rest := strings.TrimPrefix(abs, volume) | |
| @@ -161,13 +164,18 @@ func rejectSymlinkedComponents(abs string) error { | |||
| 161 | 164 | } | |
| 162 | 165 | return fmt.Errorf("brokerconfig: inspect %q: %w", current, err) | |
| 163 | 166 | } | |
| 164 | - | if info.Mode()&os.ModeSymlink != 0 { | |
| 167 | + | if info.Mode()&os.ModeSymlink != 0 && !ownedByRoot(info) { | |
| 165 | 168 | return fmt.Errorf("brokerconfig: %q is a symlink", current) | |
| 166 | 169 | } | |
| 167 | 170 | } | |
| 168 | 171 | return nil | |
| 169 | 172 | } | |
| 170 | 173 | ||
| 174 | + | func ownedByRoot(info fs.FileInfo) bool { | |
| 175 | + | stat, ok := info.Sys().(*syscall.Stat_t) | |
| 176 | + | return ok && stat.Uid == 0 | |
| 177 | + | } | |
| 178 | + | ||
| 171 | 179 | // overlaps reports whether two absolute cleaned paths are equal or one contains | |
| 172 | 180 | // the other. | |
| 173 | 181 | func overlaps(a, b string) bool { | |
| @@ -139,8 +139,11 @@ func (l Layout) VerifyCreated() error { | |||||
| 139 | } | 139 | } | ||
| 140 | 140 | ||||
| 141 | // rejectSymlinkedComponents refuses a path any of whose existing components is | 141 | // rejectSymlinkedComponents refuses a path any of whose existing components is | ||
| 142 | - | // a symlink. A component that does not exist yet ends the walk: the caller | 142 | + | // a symlink not owned by root. A component that does not exist yet ends the |
| 143 | - | // creates the remaining private directories itself. | 143 | + | // walk: the caller creates the remaining private directories itself. |
| 144 | + | // | |||
| 145 | + | // Root-owned symlinks are system layout, such as macOS /var -> private/var and | |||
| 146 | + | // /tmp -> private/tmp; no other user can plant or retarget them. | |||
| 144 | func rejectSymlinkedComponents(abs string) error { | 147 | func rejectSymlinkedComponents(abs string) error { | ||
| 145 | volume := filepath.VolumeName(abs) | 148 | volume := filepath.VolumeName(abs) | ||
| 146 | rest := strings.TrimPrefix(abs, volume) | 149 | rest := strings.TrimPrefix(abs, volume) | ||
| @@ -161,13 +164,18 @@ func rejectSymlinkedComponents(abs string) error { | |||||
| 161 | } | 164 | } | ||
| 162 | return fmt.Errorf("brokerconfig: inspect %q: %w", current, err) | 165 | return fmt.Errorf("brokerconfig: inspect %q: %w", current, err) | ||
| 163 | } | 166 | } | ||
| 164 | - | if info.Mode()&os.ModeSymlink != 0 { | 167 | + | if info.Mode()&os.ModeSymlink != 0 && !ownedByRoot(info) { |
| 165 | return fmt.Errorf("brokerconfig: %q is a symlink", current) | 168 | return fmt.Errorf("brokerconfig: %q is a symlink", current) | ||
| 166 | } | 169 | } | ||
| 167 | } | 170 | } | ||
| 168 | return nil | 171 | return nil | ||
| 169 | } | 172 | } | ||
| 170 | 173 | ||||
| 174 | + | func ownedByRoot(info fs.FileInfo) bool { | |||
| 175 | + | stat, ok := info.Sys().(*syscall.Stat_t) | |||
| 176 | + | return ok && stat.Uid == 0 | |||
| 177 | + | } | |||
| 178 | + | ||||
| 171 | // overlaps reports whether two absolute cleaned paths are equal or one contains | 179 | // overlaps reports whether two absolute cleaned paths are equal or one contains | ||
| 172 | // the other. | 180 | // the other. | ||
| 173 | func overlaps(a, b string) bool { | 181 | func overlaps(a, b string) bool { | ||
M internal/adapters/brokerconfig/config_test.go +15 -0
| @@ -748,3 +748,18 @@ func TestResolverDoesNotAliasConfiguration(t *testing.T) { | |||
| 748 | 748 | resolver.local.Identity = "changed" | |
| 749 | 749 | require.Equal(t, original.local.Identity, config.Resolver().local.Identity) | |
| 750 | 750 | } | |
| 751 | + | ||
| 752 | + | func TestRejectSymlinkedComponentsAllowsRootOwnedSystemLinks(t *testing.T) { | |
| 753 | + | var rootLink string | |
| 754 | + | for _, candidate := range []string{"/var", "/tmp", "/bin", "/lib", "/sbin"} { | |
| 755 | + | info, err := os.Lstat(candidate) | |
| 756 | + | if err == nil && info.Mode()&os.ModeSymlink != 0 && ownedByRoot(info) { | |
| 757 | + | rootLink = candidate | |
| 758 | + | break | |
| 759 | + | } | |
| 760 | + | } | |
| 761 | + | if rootLink == "" { | |
| 762 | + | t.Skip("no root-owned top-level symlink on this system") | |
| 763 | + | } | |
| 764 | + | require.NoError(t, rejectSymlinkedComponents(filepath.Join(rootLink, "vev-missing-child"))) | |
| 765 | + | } | |
| @@ -748,3 +748,18 @@ func TestResolverDoesNotAliasConfiguration(t *testing.T) { | |||||
| 748 | resolver.local.Identity = "changed" | 748 | resolver.local.Identity = "changed" | ||
| 749 | require.Equal(t, original.local.Identity, config.Resolver().local.Identity) | 749 | require.Equal(t, original.local.Identity, config.Resolver().local.Identity) | ||
| 750 | } | 750 | } | ||
| 751 | + | ||||
| 752 | + | func TestRejectSymlinkedComponentsAllowsRootOwnedSystemLinks(t *testing.T) { | |||
| 753 | + | var rootLink string | |||
| 754 | + | for _, candidate := range []string{"/var", "/tmp", "/bin", "/lib", "/sbin"} { | |||
| 755 | + | info, err := os.Lstat(candidate) | |||
| 756 | + | if err == nil && info.Mode()&os.ModeSymlink != 0 && ownedByRoot(info) { | |||
| 757 | + | rootLink = candidate | |||
| 758 | + | break | |||
| 759 | + | } | |||
| 760 | + | } | |||
| 761 | + | if rootLink == "" { | |||
| 762 | + | t.Skip("no root-owned top-level symlink on this system") | |||
| 763 | + | } | |||
| 764 | + | require.NoError(t, rejectSymlinkedComponents(filepath.Join(rootLink, "vev-missing-child"))) | |||
| 765 | + | } | |||
M internal/adapters/brokeripc/errors.go +19 -4
| @@ -3,6 +3,8 @@ package brokeripc | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/vev/internal/adapters/brokerwire" | |
| 8 | 10 | "github.com/bnema/vev/internal/ports" | |
| @@ -94,12 +96,25 @@ func admissionError(err error) error { | |||
| 94 | 96 | } | |
| 95 | 97 | ||
| 96 | 98 | // errorDetail converts one broker failure into the bounded, presentation-safe | |
| 97 | - | // wire detail. The diagnostic cause stays local: only the typed code, bounded | |
| 98 | - | // display text, closed admission code, and sanitized failure kind travel. | |
| 99 | + | // wire detail. Causes stay local, except for a physical stream loss: its | |
| 100 | + | // failure kind and a sanitized, bounded description of the transport cause | |
| 101 | + | // travel as display text so the client can report why the attachment ended. | |
| 102 | + | // Otherwise only the typed code, bounded display text, and closed admission | |
| 103 | + | // code travel. | |
| 99 | 104 | func errorDetail(err error) brokerwire.ErrorDetail { | |
| 100 | 105 | if err == nil { | |
| 101 | 106 | return brokerwire.ErrorDetail{} | |
| 102 | 107 | } | |
| 108 | + | // A physical loss keeps its failure kind and a bounded description of the | |
| 109 | + | // transport cause, so the client can report why the attachment ended. | |
| 110 | + | var lost ports.BrokerStreamLost | |
| 111 | + | if errors.As(err, &lost) && lost.Validate() == nil { | |
| 112 | + | detail := brokerwire.ErrorDetail{Code: ports.BrokerErrorAttachmentLost, Text: lost.Cause.String()} | |
| 113 | + | if lost.Err != nil { | |
| 114 | + | detail.Text = boundedBrokerErrorText(fmt.Errorf("%s: %w", lost.Cause, lost.Err)) | |
| 115 | + | } | |
| 116 | + | return detail | |
| 117 | + | } | |
| 103 | 118 | var failure ports.BrokerError | |
| 104 | 119 | if errors.As(err, &failure) { | |
| 105 | 120 | return brokerwire.ErrorDetail{Code: failure.Code, Text: failure.Text} | |
| @@ -138,9 +153,9 @@ func errorDetail(err error) brokerwire.ErrorDetail { | |||
| 138 | 153 | ||
| 139 | 154 | func boundedBrokerErrorText(err error) string { | |
| 140 | 155 | const max = 256 | |
| 141 | - | text := err.Error() | |
| 156 | + | text := ports.SanitizeBrokerDisplayText(err.Error()) | |
| 142 | 157 | if len(text) > max { | |
| 143 | - | text = text[:max] | |
| 158 | + | text = strings.ToValidUTF8(text[:max], "") | |
| 144 | 159 | } | |
| 145 | 160 | return text | |
| 146 | 161 | } | |
| @@ -3,6 +3,8 @@ package brokeripc | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 6 | 8 | ||||
| 7 | "github.com/bnema/vev/internal/adapters/brokerwire" | 9 | "github.com/bnema/vev/internal/adapters/brokerwire" | ||
| 8 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| @@ -94,12 +96,25 @@ func admissionError(err error) error { | |||||
| 94 | } | 96 | } | ||
| 95 | 97 | ||||
| 96 | // errorDetail converts one broker failure into the bounded, presentation-safe | 98 | // errorDetail converts one broker failure into the bounded, presentation-safe | ||
| 97 | - | // wire detail. The diagnostic cause stays local: only the typed code, bounded | 99 | + | // wire detail. Causes stay local, except for a physical stream loss: its |
| 98 | - | // display text, closed admission code, and sanitized failure kind travel. | 100 | + | // failure kind and a sanitized, bounded description of the transport cause |
| 101 | + | // travel as display text so the client can report why the attachment ended. | |||
| 102 | + | // Otherwise only the typed code, bounded display text, and closed admission | |||
| 103 | + | // code travel. | |||
| 99 | func errorDetail(err error) brokerwire.ErrorDetail { | 104 | func errorDetail(err error) brokerwire.ErrorDetail { | ||
| 100 | if err == nil { | 105 | if err == nil { | ||
| 101 | return brokerwire.ErrorDetail{} | 106 | return brokerwire.ErrorDetail{} | ||
| 102 | } | 107 | } | ||
| 108 | + | // A physical loss keeps its failure kind and a bounded description of the | |||
| 109 | + | // transport cause, so the client can report why the attachment ended. | |||
| 110 | + | var lost ports.BrokerStreamLost | |||
| 111 | + | if errors.As(err, &lost) && lost.Validate() == nil { | |||
| 112 | + | detail := brokerwire.ErrorDetail{Code: ports.BrokerErrorAttachmentLost, Text: lost.Cause.String()} | |||
| 113 | + | if lost.Err != nil { | |||
| 114 | + | detail.Text = boundedBrokerErrorText(fmt.Errorf("%s: %w", lost.Cause, lost.Err)) | |||
| 115 | + | } | |||
| 116 | + | return detail | |||
| 117 | + | } | |||
| 103 | var failure ports.BrokerError | 118 | var failure ports.BrokerError | ||
| 104 | if errors.As(err, &failure) { | 119 | if errors.As(err, &failure) { | ||
| 105 | return brokerwire.ErrorDetail{Code: failure.Code, Text: failure.Text} | 120 | return brokerwire.ErrorDetail{Code: failure.Code, Text: failure.Text} | ||
| @@ -138,9 +153,9 @@ func errorDetail(err error) brokerwire.ErrorDetail { | |||||
| 138 | 153 | ||||
| 139 | func boundedBrokerErrorText(err error) string { | 154 | func boundedBrokerErrorText(err error) string { | ||
| 140 | const max = 256 | 155 | const max = 256 | ||
| 141 | - | text := err.Error() | 156 | + | text := ports.SanitizeBrokerDisplayText(err.Error()) |
| 142 | if len(text) > max { | 157 | if len(text) > max { | ||
| 143 | - | text = text[:max] | 158 | + | text = strings.ToValidUTF8(text[:max], "") |
| 144 | } | 159 | } | ||
| 145 | return text | 160 | return text | ||
| 146 | } | 161 | } | ||
M internal/adapters/brokeripc/limits_test.go +40 -0
| @@ -3,9 +3,11 @@ package brokeripc | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "io" | |
| 7 | 8 | "io/fs" | |
| 8 | 9 | "net" | |
| 10 | + | "os" | |
| 9 | 11 | "sync" | |
| 10 | 12 | "syscall" | |
| 11 | 13 | "testing" | |
| @@ -14,6 +16,8 @@ import ( | |||
| 14 | 16 | "github.com/stretchr/testify/require" | |
| 15 | 17 | ||
| 16 | 18 | "github.com/bnema/vev/internal/adapters/brokerwire" | |
| 19 | + | "github.com/bnema/vev/internal/adapters/streamframe" | |
| 20 | + | "github.com/bnema/vev/internal/domain" | |
| 17 | 21 | "github.com/bnema/vev/internal/ports" | |
| 18 | 22 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 19 | 23 | "github.com/bnema/vev/internal/protocol/wire" | |
| @@ -260,6 +264,40 @@ func TestErrorDetailMapping(t *testing.T) { | |||
| 260 | 264 | require.ErrorIs(t, scoped, ports.BrokerAdmissionLimit) | |
| 261 | 265 | } | |
| 262 | 266 | ||
| 267 | + | // TestErrorDetailKeepsStreamLossCause proves a physical stream loss reaches | |
| 268 | + | // the client with its failure kind and a bounded, display-safe cause in the | |
| 269 | + | // display text instead of a bare attachment_lost. | |
| 270 | + | func TestErrorDetailKeepsStreamLossCause(t *testing.T) { | |
| 271 | + | lost := func(err error) ports.BrokerStreamLost { | |
| 272 | + | return ports.BrokerStreamLost{Connection: ports.BrokerConnectionID{1}, Stream: 1, Epoch: 1, Cause: domain.RemoteFailureTimeout, Err: err} | |
| 273 | + | } | |
| 274 | + | cases := []struct { | |
| 275 | + | name string | |
| 276 | + | err error | |
| 277 | + | want string | |
| 278 | + | }{ | |
| 279 | + | {"with cause", lost(errors.New("quic: idle timeout")), "timeout: quic: idle timeout"}, | |
| 280 | + | {"without cause", lost(nil), "timeout"}, | |
| 281 | + | {"wrapped", fmt.Errorf("attach: %w", lost(errors.New("reset"))), "timeout: reset"}, | |
| 282 | + | {"control characters removed", lost(errors.New("bad\x1b[31m\nline")), "timeout: bad[31mline"}, | |
| 283 | + | } | |
| 284 | + | for _, tc := range cases { | |
| 285 | + | t.Run(tc.name, func(t *testing.T) { | |
| 286 | + | detail := errorDetail(tc.err) | |
| 287 | + | require.Equal(t, ports.BrokerErrorAttachmentLost, detail.Code) | |
| 288 | + | require.Equal(t, tc.want, detail.Text) | |
| 289 | + | _, err := brokerwire.EncodeServer(brokerwire.StreamClosed{ | |
| 290 | + | Epoch: 1, Connection: ports.BrokerConnectionID{1}, Stream: 1, Error: detail, HasError: true, | |
| 291 | + | }, brokerwire.MaxBrokerEnvelopeBytes, brokerwire.MaxStreamChunkBytes) | |
| 292 | + | require.NoError(t, err) | |
| 293 | + | ||
| 294 | + | var typed ports.BrokerError | |
| 295 | + | require.ErrorAs(t, failureFromDetail(detail), &typed) | |
| 296 | + | require.Equal(t, tc.want, typed.Text) | |
| 297 | + | }) | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 263 | 301 | // TestAdmissionErrorMapping proves connection-tracker refusals map onto the | |
| 264 | 302 | // closed ports admission taxonomy. | |
| 265 | 303 | func TestAdmissionErrorMapping(t *testing.T) { | |
| @@ -361,8 +399,10 @@ func TestOrderlyDisconnectClassification(t *testing.T) { | |||
| 361 | 399 | require.True(t, orderlyDisconnect(ErrConnectionClosed)) | |
| 362 | 400 | require.True(t, orderlyDisconnect(errors.Join(ErrRegistrationTimeout, context.DeadlineExceeded))) | |
| 363 | 401 | require.True(t, orderlyDisconnect(&net.OpError{Op: "read", Net: "unix", Err: syscall.ECONNRESET})) | |
| 402 | + | require.True(t, orderlyDisconnect(&net.OpError{Op: "write", Net: "unix", Err: os.NewSyscallError("write", syscall.EPIPE)})) | |
| 364 | 403 | require.True(t, orderlyDisconnect(net.ErrClosed)) | |
| 365 | 404 | require.True(t, orderlyDisconnect(fs.ErrClosed)) | |
| 405 | + | require.True(t, orderlyDisconnect(fmt.Errorf("write: %w", streamframe.ErrClosed))) | |
| 366 | 406 | require.False(t, orderlyDisconnect(ErrMalformedFrame)) | |
| 367 | 407 | require.False(t, orderlyDisconnect(ErrProtocol)) | |
| 368 | 408 | } | |
| @@ -3,9 +3,11 @@ package brokeripc | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "fmt" | |||
| 6 | "io" | 7 | "io" | ||
| 7 | "io/fs" | 8 | "io/fs" | ||
| 8 | "net" | 9 | "net" | ||
| 10 | + | "os" | |||
| 9 | "sync" | 11 | "sync" | ||
| 10 | "syscall" | 12 | "syscall" | ||
| 11 | "testing" | 13 | "testing" | ||
| @@ -14,6 +16,8 @@ import ( | |||||
| 14 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| 15 | 17 | ||||
| 16 | "github.com/bnema/vev/internal/adapters/brokerwire" | 18 | "github.com/bnema/vev/internal/adapters/brokerwire" | ||
| 19 | + | "github.com/bnema/vev/internal/adapters/streamframe" | |||
| 20 | + | "github.com/bnema/vev/internal/domain" | |||
| 17 | "github.com/bnema/vev/internal/ports" | 21 | "github.com/bnema/vev/internal/ports" | ||
| 18 | "github.com/bnema/vev/internal/protocol/catalogue" | 22 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| 19 | "github.com/bnema/vev/internal/protocol/wire" | 23 | "github.com/bnema/vev/internal/protocol/wire" | ||
| @@ -260,6 +264,40 @@ func TestErrorDetailMapping(t *testing.T) { | |||||
| 260 | require.ErrorIs(t, scoped, ports.BrokerAdmissionLimit) | 264 | require.ErrorIs(t, scoped, ports.BrokerAdmissionLimit) | ||
| 261 | } | 265 | } | ||
| 262 | 266 | ||||
| 267 | + | // TestErrorDetailKeepsStreamLossCause proves a physical stream loss reaches | |||
| 268 | + | // the client with its failure kind and a bounded, display-safe cause in the | |||
| 269 | + | // display text instead of a bare attachment_lost. | |||
| 270 | + | func TestErrorDetailKeepsStreamLossCause(t *testing.T) { | |||
| 271 | + | lost := func(err error) ports.BrokerStreamLost { | |||
| 272 | + | return ports.BrokerStreamLost{Connection: ports.BrokerConnectionID{1}, Stream: 1, Epoch: 1, Cause: domain.RemoteFailureTimeout, Err: err} | |||
| 273 | + | } | |||
| 274 | + | cases := []struct { | |||
| 275 | + | name string | |||
| 276 | + | err error | |||
| 277 | + | want string | |||
| 278 | + | }{ | |||
| 279 | + | {"with cause", lost(errors.New("quic: idle timeout")), "timeout: quic: idle timeout"}, | |||
| 280 | + | {"without cause", lost(nil), "timeout"}, | |||
| 281 | + | {"wrapped", fmt.Errorf("attach: %w", lost(errors.New("reset"))), "timeout: reset"}, | |||
| 282 | + | {"control characters removed", lost(errors.New("bad\x1b[31m\nline")), "timeout: bad[31mline"}, | |||
| 283 | + | } | |||
| 284 | + | for _, tc := range cases { | |||
| 285 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 286 | + | detail := errorDetail(tc.err) | |||
| 287 | + | require.Equal(t, ports.BrokerErrorAttachmentLost, detail.Code) | |||
| 288 | + | require.Equal(t, tc.want, detail.Text) | |||
| 289 | + | _, err := brokerwire.EncodeServer(brokerwire.StreamClosed{ | |||
| 290 | + | Epoch: 1, Connection: ports.BrokerConnectionID{1}, Stream: 1, Error: detail, HasError: true, | |||
| 291 | + | }, brokerwire.MaxBrokerEnvelopeBytes, brokerwire.MaxStreamChunkBytes) | |||
| 292 | + | require.NoError(t, err) | |||
| 293 | + | ||||
| 294 | + | var typed ports.BrokerError | |||
| 295 | + | require.ErrorAs(t, failureFromDetail(detail), &typed) | |||
| 296 | + | require.Equal(t, tc.want, typed.Text) | |||
| 297 | + | }) | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | ||||
| 263 | // TestAdmissionErrorMapping proves connection-tracker refusals map onto the | 301 | // TestAdmissionErrorMapping proves connection-tracker refusals map onto the | ||
| 264 | // closed ports admission taxonomy. | 302 | // closed ports admission taxonomy. | ||
| 265 | func TestAdmissionErrorMapping(t *testing.T) { | 303 | func TestAdmissionErrorMapping(t *testing.T) { | ||
| @@ -361,8 +399,10 @@ func TestOrderlyDisconnectClassification(t *testing.T) { | |||||
| 361 | require.True(t, orderlyDisconnect(ErrConnectionClosed)) | 399 | require.True(t, orderlyDisconnect(ErrConnectionClosed)) | ||
| 362 | require.True(t, orderlyDisconnect(errors.Join(ErrRegistrationTimeout, context.DeadlineExceeded))) | 400 | require.True(t, orderlyDisconnect(errors.Join(ErrRegistrationTimeout, context.DeadlineExceeded))) | ||
| 363 | require.True(t, orderlyDisconnect(&net.OpError{Op: "read", Net: "unix", Err: syscall.ECONNRESET})) | 401 | require.True(t, orderlyDisconnect(&net.OpError{Op: "read", Net: "unix", Err: syscall.ECONNRESET})) | ||
| 402 | + | require.True(t, orderlyDisconnect(&net.OpError{Op: "write", Net: "unix", Err: os.NewSyscallError("write", syscall.EPIPE)})) | |||
| 364 | require.True(t, orderlyDisconnect(net.ErrClosed)) | 403 | require.True(t, orderlyDisconnect(net.ErrClosed)) | ||
| 365 | require.True(t, orderlyDisconnect(fs.ErrClosed)) | 404 | require.True(t, orderlyDisconnect(fs.ErrClosed)) | ||
| 405 | + | require.True(t, orderlyDisconnect(fmt.Errorf("write: %w", streamframe.ErrClosed))) | |||
| 366 | require.False(t, orderlyDisconnect(ErrMalformedFrame)) | 406 | require.False(t, orderlyDisconnect(ErrMalformedFrame)) | ||
| 367 | require.False(t, orderlyDisconnect(ErrProtocol)) | 407 | require.False(t, orderlyDisconnect(ErrProtocol)) | ||
| 368 | } | 408 | } | ||
M internal/adapters/brokeripc/server.go +8 -3
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/vev/internal/adapters/brokerwire" | |
| 16 | 16 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 17 | + | "github.com/bnema/vev/internal/adapters/streamframe" | |
| 17 | 18 | "github.com/bnema/vev/internal/domain" | |
| 18 | 19 | "github.com/bnema/vev/internal/ports" | |
| 19 | 20 | "github.com/bnema/vev/internal/protocol/wire" | |
| @@ -917,15 +918,19 @@ func (s *serverSession) Close() error { | |||
| 917 | 918 | // outcome is the peer's departure, so a listener draining its sessions never | |
| 918 | 919 | // reports it as its own failure. | |
| 919 | 920 | // | |
| 920 | - | // A peer that closes abruptly can surface a reset (or a local-close sentinel | |
| 921 | - | // from a concurrently interrupted read) instead of a clean EOF; both are still | |
| 922 | - | // the peer's or this side's ordinary departure, never a sandbox failure. | |
| 921 | + | // A peer that closes abruptly can surface a reset, a broken pipe on the next | |
| 922 | + | // write, or a local-close sentinel from a concurrently interrupted read instead | |
| 923 | + | // of a clean EOF; all are still | |
| 924 | + | // the peer's or this side's ordinary departure, never a sandbox failure. The | |
| 925 | + | // same holds for a frame write interrupted by this side's own carriage close. | |
| 923 | 926 | func orderlyDisconnect(err error) bool { | |
| 924 | 927 | return err == nil || | |
| 925 | 928 | errors.Is(err, io.EOF) || | |
| 926 | 929 | errors.Is(err, syscall.ECONNRESET) || | |
| 930 | + | errors.Is(err, syscall.EPIPE) || | |
| 927 | 931 | errors.Is(err, net.ErrClosed) || | |
| 928 | 932 | errors.Is(err, fs.ErrClosed) || | |
| 933 | + | errors.Is(err, streamframe.ErrClosed) || | |
| 929 | 934 | errors.Is(err, ErrConnectionClosed) || | |
| 930 | 935 | errors.Is(err, ErrSessionClosed) || | |
| 931 | 936 | errors.Is(err, ErrRegistrationTimeout) | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | "github.com/bnema/vev/internal/adapters/brokerwire" | 15 | "github.com/bnema/vev/internal/adapters/brokerwire" | ||
| 16 | "github.com/bnema/vev/internal/adapters/ipc" | 16 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 17 | + | "github.com/bnema/vev/internal/adapters/streamframe" | |||
| 17 | "github.com/bnema/vev/internal/domain" | 18 | "github.com/bnema/vev/internal/domain" | ||
| 18 | "github.com/bnema/vev/internal/ports" | 19 | "github.com/bnema/vev/internal/ports" | ||
| 19 | "github.com/bnema/vev/internal/protocol/wire" | 20 | "github.com/bnema/vev/internal/protocol/wire" | ||
| @@ -917,15 +918,19 @@ func (s *serverSession) Close() error { | |||||
| 917 | // outcome is the peer's departure, so a listener draining its sessions never | 918 | // outcome is the peer's departure, so a listener draining its sessions never | ||
| 918 | // reports it as its own failure. | 919 | // reports it as its own failure. | ||
| 919 | // | 920 | // | ||
| 920 | - | // A peer that closes abruptly can surface a reset (or a local-close sentinel | 921 | + | // A peer that closes abruptly can surface a reset, a broken pipe on the next |
| 921 | - | // from a concurrently interrupted read) instead of a clean EOF; both are still | 922 | + | // write, or a local-close sentinel from a concurrently interrupted read instead |
| 922 | - | // the peer's or this side's ordinary departure, never a sandbox failure. | 923 | + | // of a clean EOF; all are still |
| 924 | + | // the peer's or this side's ordinary departure, never a sandbox failure. The | |||
| 925 | + | // same holds for a frame write interrupted by this side's own carriage close. | |||
| 923 | func orderlyDisconnect(err error) bool { | 926 | func orderlyDisconnect(err error) bool { | ||
| 924 | return err == nil || | 927 | return err == nil || | ||
| 925 | errors.Is(err, io.EOF) || | 928 | errors.Is(err, io.EOF) || | ||
| 926 | errors.Is(err, syscall.ECONNRESET) || | 929 | errors.Is(err, syscall.ECONNRESET) || | ||
| 930 | + | errors.Is(err, syscall.EPIPE) || | |||
| 927 | errors.Is(err, net.ErrClosed) || | 931 | errors.Is(err, net.ErrClosed) || | ||
| 928 | errors.Is(err, fs.ErrClosed) || | 932 | errors.Is(err, fs.ErrClosed) || | ||
| 933 | + | errors.Is(err, streamframe.ErrClosed) || | |||
| 929 | errors.Is(err, ErrConnectionClosed) || | 934 | errors.Is(err, ErrConnectionClosed) || | ||
| 930 | errors.Is(err, ErrSessionClosed) || | 935 | errors.Is(err, ErrSessionClosed) || | ||
| 931 | errors.Is(err, ErrRegistrationTimeout) | 936 | errors.Is(err, ErrRegistrationTimeout) | ||
M internal/adapters/daemonmux/codec.go +51 -0
| @@ -146,6 +146,17 @@ func encodeClientEnvelope(message ClientMessage, maxChunkBytes uint64) (*wire.Mu | |||
| 146 | 146 | return nil, ErrInvalidMessage | |
| 147 | 147 | } | |
| 148 | 148 | return encodeClientEnvelope(*m, maxChunkBytes) | |
| 149 | + | case WindowUpdate: | |
| 150 | + | converted, err := windowUpdateToWire(m) | |
| 151 | + | if err != nil { | |
| 152 | + | return nil, err | |
| 153 | + | } | |
| 154 | + | return &wire.MuxClientEnvelope{Payload: &wire.MuxClientEnvelope_WindowUpdate{WindowUpdate: converted}}, nil | |
| 155 | + | case *WindowUpdate: | |
| 156 | + | if m == nil { | |
| 157 | + | return nil, ErrInvalidMessage | |
| 158 | + | } | |
| 159 | + | return encodeClientEnvelope(*m, maxChunkBytes) | |
| 149 | 160 | default: | |
| 150 | 161 | return nil, ErrWrongDirection | |
| 151 | 162 | } | |
| @@ -167,6 +178,8 @@ func decodeClientEnvelope(envelope *wire.MuxClientEnvelope, maxChunkBytes uint64 | |||
| 167 | 178 | return closeFromWire(payload.Close) | |
| 168 | 179 | case *wire.MuxClientEnvelope_Reset_: | |
| 169 | 180 | return resetFromWire(payload.Reset_) | |
| 181 | + | case *wire.MuxClientEnvelope_WindowUpdate: | |
| 182 | + | return windowUpdateFromWire(payload.WindowUpdate) | |
| 170 | 183 | default: | |
| 171 | 184 | return nil, ErrWrongDirection | |
| 172 | 185 | } | |
| @@ -233,6 +246,17 @@ func encodeServerEnvelope(message ServerMessage, maxChunkBytes uint64) (*wire.Mu | |||
| 233 | 246 | return nil, ErrInvalidMessage | |
| 234 | 247 | } | |
| 235 | 248 | return encodeServerEnvelope(*m, maxChunkBytes) | |
| 249 | + | case WindowUpdate: | |
| 250 | + | converted, err := windowUpdateToWire(m) | |
| 251 | + | if err != nil { | |
| 252 | + | return nil, err | |
| 253 | + | } | |
| 254 | + | return &wire.MuxServerEnvelope{Payload: &wire.MuxServerEnvelope_WindowUpdate{WindowUpdate: converted}}, nil | |
| 255 | + | case *WindowUpdate: | |
| 256 | + | if m == nil { | |
| 257 | + | return nil, ErrInvalidMessage | |
| 258 | + | } | |
| 259 | + | return encodeServerEnvelope(*m, maxChunkBytes) | |
| 236 | 260 | default: | |
| 237 | 261 | return nil, ErrWrongDirection | |
| 238 | 262 | } | |
| @@ -260,6 +284,8 @@ func decodeServerEnvelope(envelope *wire.MuxServerEnvelope, maxChunkBytes uint64 | |||
| 260 | 284 | return closeFromWire(payload.Close) | |
| 261 | 285 | case *wire.MuxServerEnvelope_Reset_: | |
| 262 | 286 | return resetFromWire(payload.Reset_) | |
| 287 | + | case *wire.MuxServerEnvelope_WindowUpdate: | |
| 288 | + | return windowUpdateFromWire(payload.WindowUpdate) | |
| 263 | 289 | default: | |
| 264 | 290 | return nil, ErrWrongDirection | |
| 265 | 291 | } | |
| @@ -470,6 +496,31 @@ func closeFromWire(message *wire.MuxClose) (Close, error) { | |||
| 470 | 496 | return Close{Physical: physical}, nil | |
| 471 | 497 | } | |
| 472 | 498 | ||
| 499 | + | func windowUpdateToWire(m WindowUpdate) (*wire.MuxWindowUpdate, error) { | |
| 500 | + | if err := m.Physical.Validate(); err != nil { | |
| 501 | + | return nil, ErrInvalidMessage | |
| 502 | + | } | |
| 503 | + | if m.Credit == 0 || m.Credit > MaxMuxStreamWindowBytes { | |
| 504 | + | return nil, ErrInvalidMessage | |
| 505 | + | } | |
| 506 | + | return &wire.MuxWindowUpdate{PhysicalStreamId: physicalToWire(m.Physical), CreditBytes: m.Credit}, nil | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | func windowUpdateFromWire(message *wire.MuxWindowUpdate) (WindowUpdate, error) { | |
| 510 | + | if message == nil { | |
| 511 | + | return WindowUpdate{}, ErrInvalidMessage | |
| 512 | + | } | |
| 513 | + | physical, err := physicalFromWire(message.GetPhysicalStreamId()) | |
| 514 | + | if err != nil { | |
| 515 | + | return WindowUpdate{}, ErrInvalidMessage | |
| 516 | + | } | |
| 517 | + | credit := message.GetCreditBytes() | |
| 518 | + | if credit == 0 || credit > MaxMuxStreamWindowBytes { | |
| 519 | + | return WindowUpdate{}, ErrInvalidMessage | |
| 520 | + | } | |
| 521 | + | return WindowUpdate{Physical: physical, Credit: credit}, nil | |
| 522 | + | } | |
| 523 | + | ||
| 473 | 524 | func resetToWire(m Reset) (*wire.MuxReset, error) { | |
| 474 | 525 | if err := m.Physical.Validate(); err != nil { | |
| 475 | 526 | return nil, ErrInvalidMessage | |
| @@ -146,6 +146,17 @@ func encodeClientEnvelope(message ClientMessage, maxChunkBytes uint64) (*wire.Mu | |||||
| 146 | return nil, ErrInvalidMessage | 146 | return nil, ErrInvalidMessage | ||
| 147 | } | 147 | } | ||
| 148 | return encodeClientEnvelope(*m, maxChunkBytes) | 148 | return encodeClientEnvelope(*m, maxChunkBytes) | ||
| 149 | + | case WindowUpdate: | |||
| 150 | + | converted, err := windowUpdateToWire(m) | |||
| 151 | + | if err != nil { | |||
| 152 | + | return nil, err | |||
| 153 | + | } | |||
| 154 | + | return &wire.MuxClientEnvelope{Payload: &wire.MuxClientEnvelope_WindowUpdate{WindowUpdate: converted}}, nil | |||
| 155 | + | case *WindowUpdate: | |||
| 156 | + | if m == nil { | |||
| 157 | + | return nil, ErrInvalidMessage | |||
| 158 | + | } | |||
| 159 | + | return encodeClientEnvelope(*m, maxChunkBytes) | |||
| 149 | default: | 160 | default: | ||
| 150 | return nil, ErrWrongDirection | 161 | return nil, ErrWrongDirection | ||
| 151 | } | 162 | } | ||
| @@ -167,6 +178,8 @@ func decodeClientEnvelope(envelope *wire.MuxClientEnvelope, maxChunkBytes uint64 | |||||
| 167 | return closeFromWire(payload.Close) | 178 | return closeFromWire(payload.Close) | ||
| 168 | case *wire.MuxClientEnvelope_Reset_: | 179 | case *wire.MuxClientEnvelope_Reset_: | ||
| 169 | return resetFromWire(payload.Reset_) | 180 | return resetFromWire(payload.Reset_) | ||
| 181 | + | case *wire.MuxClientEnvelope_WindowUpdate: | |||
| 182 | + | return windowUpdateFromWire(payload.WindowUpdate) | |||
| 170 | default: | 183 | default: | ||
| 171 | return nil, ErrWrongDirection | 184 | return nil, ErrWrongDirection | ||
| 172 | } | 185 | } | ||
| @@ -233,6 +246,17 @@ func encodeServerEnvelope(message ServerMessage, maxChunkBytes uint64) (*wire.Mu | |||||
| 233 | return nil, ErrInvalidMessage | 246 | return nil, ErrInvalidMessage | ||
| 234 | } | 247 | } | ||
| 235 | return encodeServerEnvelope(*m, maxChunkBytes) | 248 | return encodeServerEnvelope(*m, maxChunkBytes) | ||
| 249 | + | case WindowUpdate: | |||
| 250 | + | converted, err := windowUpdateToWire(m) | |||
| 251 | + | if err != nil { | |||
| 252 | + | return nil, err | |||
| 253 | + | } | |||
| 254 | + | return &wire.MuxServerEnvelope{Payload: &wire.MuxServerEnvelope_WindowUpdate{WindowUpdate: converted}}, nil | |||
| 255 | + | case *WindowUpdate: | |||
| 256 | + | if m == nil { | |||
| 257 | + | return nil, ErrInvalidMessage | |||
| 258 | + | } | |||
| 259 | + | return encodeServerEnvelope(*m, maxChunkBytes) | |||
| 236 | default: | 260 | default: | ||
| 237 | return nil, ErrWrongDirection | 261 | return nil, ErrWrongDirection | ||
| 238 | } | 262 | } | ||
| @@ -260,6 +284,8 @@ func decodeServerEnvelope(envelope *wire.MuxServerEnvelope, maxChunkBytes uint64 | |||||
| 260 | return closeFromWire(payload.Close) | 284 | return closeFromWire(payload.Close) | ||
| 261 | case *wire.MuxServerEnvelope_Reset_: | 285 | case *wire.MuxServerEnvelope_Reset_: | ||
| 262 | return resetFromWire(payload.Reset_) | 286 | return resetFromWire(payload.Reset_) | ||
| 287 | + | case *wire.MuxServerEnvelope_WindowUpdate: | |||
| 288 | + | return windowUpdateFromWire(payload.WindowUpdate) | |||
| 263 | default: | 289 | default: | ||
| 264 | return nil, ErrWrongDirection | 290 | return nil, ErrWrongDirection | ||
| 265 | } | 291 | } | ||
| @@ -470,6 +496,31 @@ func closeFromWire(message *wire.MuxClose) (Close, error) { | |||||
| 470 | return Close{Physical: physical}, nil | 496 | return Close{Physical: physical}, nil | ||
| 471 | } | 497 | } | ||
| 472 | 498 | ||||
| 499 | + | func windowUpdateToWire(m WindowUpdate) (*wire.MuxWindowUpdate, error) { | |||
| 500 | + | if err := m.Physical.Validate(); err != nil { | |||
| 501 | + | return nil, ErrInvalidMessage | |||
| 502 | + | } | |||
| 503 | + | if m.Credit == 0 || m.Credit > MaxMuxStreamWindowBytes { | |||
| 504 | + | return nil, ErrInvalidMessage | |||
| 505 | + | } | |||
| 506 | + | return &wire.MuxWindowUpdate{PhysicalStreamId: physicalToWire(m.Physical), CreditBytes: m.Credit}, nil | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | func windowUpdateFromWire(message *wire.MuxWindowUpdate) (WindowUpdate, error) { | |||
| 510 | + | if message == nil { | |||
| 511 | + | return WindowUpdate{}, ErrInvalidMessage | |||
| 512 | + | } | |||
| 513 | + | physical, err := physicalFromWire(message.GetPhysicalStreamId()) | |||
| 514 | + | if err != nil { | |||
| 515 | + | return WindowUpdate{}, ErrInvalidMessage | |||
| 516 | + | } | |||
| 517 | + | credit := message.GetCreditBytes() | |||
| 518 | + | if credit == 0 || credit > MaxMuxStreamWindowBytes { | |||
| 519 | + | return WindowUpdate{}, ErrInvalidMessage | |||
| 520 | + | } | |||
| 521 | + | return WindowUpdate{Physical: physical, Credit: credit}, nil | |||
| 522 | + | } | |||
| 523 | + | ||||
| 473 | func resetToWire(m Reset) (*wire.MuxReset, error) { | 524 | func resetToWire(m Reset) (*wire.MuxReset, error) { | ||
| 474 | if err := m.Physical.Validate(); err != nil { | 525 | if err := m.Physical.Validate(); err != nil { | ||
| 475 | return nil, ErrInvalidMessage | 526 | return nil, ErrInvalidMessage | ||
M internal/adapters/daemonmux/codec_test.go +2 -0
| @@ -126,6 +126,7 @@ func TestMuxClientVariants(t *testing.T) { | |||
| 126 | 126 | {"close", Close{Physical: 5}, 303}, | |
| 127 | 127 | {"reset", Reset{Physical: 5, Error: testErrorDetail(), HasError: true}, 304}, | |
| 128 | 128 | {"reset_orderly", Reset{Physical: 5}, 304}, | |
| 129 | + | {"window_update", WindowUpdate{Physical: 5, Credit: 4096}, 305}, | |
| 129 | 130 | } | |
| 130 | 131 | for _, tc := range messages { | |
| 131 | 132 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -155,6 +156,7 @@ func TestMuxServerVariants(t *testing.T) { | |||
| 155 | 156 | {"close", Close{Physical: 5}, 404}, | |
| 156 | 157 | {"reset", Reset{Physical: 5, Error: testErrorDetail(), HasError: true}, 405}, | |
| 157 | 158 | {"reset_orderly", Reset{Physical: 5}, 405}, | |
| 159 | + | {"window_update", WindowUpdate{Physical: 5, Credit: 4096}, 406}, | |
| 158 | 160 | } | |
| 159 | 161 | for _, tc := range messages { | |
| 160 | 162 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -126,6 +126,7 @@ func TestMuxClientVariants(t *testing.T) { | |||||
| 126 | {"close", Close{Physical: 5}, 303}, | 126 | {"close", Close{Physical: 5}, 303}, | ||
| 127 | {"reset", Reset{Physical: 5, Error: testErrorDetail(), HasError: true}, 304}, | 127 | {"reset", Reset{Physical: 5, Error: testErrorDetail(), HasError: true}, 304}, | ||
| 128 | {"reset_orderly", Reset{Physical: 5}, 304}, | 128 | {"reset_orderly", Reset{Physical: 5}, 304}, | ||
| 129 | + | {"window_update", WindowUpdate{Physical: 5, Credit: 4096}, 305}, | |||
| 129 | } | 130 | } | ||
| 130 | for _, tc := range messages { | 131 | for _, tc := range messages { | ||
| 131 | t.Run(tc.name, func(t *testing.T) { | 132 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -155,6 +156,7 @@ func TestMuxServerVariants(t *testing.T) { | |||||
| 155 | {"close", Close{Physical: 5}, 404}, | 156 | {"close", Close{Physical: 5}, 404}, | ||
| 156 | {"reset", Reset{Physical: 5, Error: testErrorDetail(), HasError: true}, 405}, | 157 | {"reset", Reset{Physical: 5, Error: testErrorDetail(), HasError: true}, 405}, | ||
| 157 | {"reset_orderly", Reset{Physical: 5}, 405}, | 158 | {"reset_orderly", Reset{Physical: 5}, 405}, | ||
| 159 | + | {"window_update", WindowUpdate{Physical: 5, Credit: 4096}, 406}, | |||
| 158 | } | 160 | } | ||
| 159 | for _, tc := range messages { | 161 | for _, tc := range messages { | ||
| 160 | t.Run(tc.name, func(t *testing.T) { | 162 | t.Run(tc.name, func(t *testing.T) { | ||
M internal/adapters/daemonmux/engine_state.go +151 -38
| @@ -33,17 +33,22 @@ | |||
| 33 | 33 | // that always discards the queue immediately. Neither Close nor Reset ever | |
| 34 | 34 | // terminalizes the physical connection or a sibling stream. | |
| 35 | 35 | // | |
| 36 | - | // The inbound queue is bounded, and bytes are charged against every budget | |
| 37 | - | // before the chunk is copied into the queue: at most MaxMuxStreamQueueChunks | |
| 38 | - | // chunks and MaxMuxStreamQueueBytes bytes per stream, the negotiated aggregate | |
| 39 | - | // ceiling (MuxCeilings.MaxAggregateBytes) across the connection, and the | |
| 40 | - | // negotiated chunk ceiling (MuxCeilings.StreamChunkLimit) per chunk. A chunk | |
| 41 | - | // above the negotiated chunk ceiling is refused statelessly with ErrTooLarge | |
| 42 | - | // and mutates nothing, exactly as the codec refuses it. A chunk that would | |
| 43 | - | // exceed a queue bound resets exactly that stream and releases the bytes it | |
| 44 | - | // held; sibling streams keep their budget and keep progressing. The per-stream | |
| 45 | - | // byte bound is defensive: the codec already refuses a chunk above the | |
| 46 | - | // negotiated chunk ceiling, so a conforming caller never reaches it. | |
| 36 | + | // Every stream is credit flow controlled in both directions. Each side starts | |
| 37 | + | // with the same per-stream window (MuxCeilings.StreamWindow) of send credit; a | |
| 38 | + | // Data frame costs its payload plus a fixed overhead (chunkCredit), and the | |
| 39 | + | // receiver returns credit with WindowUpdate as its consumer takes chunks. A | |
| 40 | + | // conforming sender therefore waits for credit instead of overrunning the | |
| 41 | + | // receiver, so a slow network or a slow consumer only slows the stream. | |
| 42 | + | // | |
| 43 | + | // The inbound queue is bounded by that same window, and bytes are charged | |
| 44 | + | // against every budget before the chunk is copied into the queue: the stream | |
| 45 | + | // window per stream, the negotiated aggregate ceiling | |
| 46 | + | // (MuxCeilings.MaxAggregateBytes) across the connection, and the negotiated | |
| 47 | + | // chunk ceiling (MuxCeilings.StreamChunkLimit) per chunk. A chunk above the | |
| 48 | + | // negotiated chunk ceiling is refused statelessly with ErrTooLarge and mutates | |
| 49 | + | // nothing, exactly as the codec refuses it. A chunk beyond the window is a | |
| 50 | + | // peer flow-control violation: it resets exactly that stream and releases the | |
| 51 | + | // bytes it held; sibling streams keep their budget and keep progressing. | |
| 47 | 52 | // | |
| 48 | 53 | // Terminal publication is deliberately ordered: a physical terminal outcome | |
| 49 | 54 | // publishes its own Done first and only then terminalizes every live stream | |
| @@ -71,15 +76,8 @@ import ( | |||
| 71 | 76 | ||
| 72 | 77 | // Stream queue bounds. The stream-count and aggregate-bytes ceilings are the | |
| 73 | 78 | // negotiated maxima from limits.go (MaxMuxStreams, MaxMuxAggregateBytes); the | |
| 74 | - | // per-stream chunk and byte ceilings are engine-local. | |
| 79 | + | // per-stream bound is the flow-control window (MuxCeilings.StreamWindow). | |
| 75 | 80 | const ( | |
| 76 | - | // MaxMuxStreamQueueChunks bounds the inbound chunks queued for one stream. | |
| 77 | - | MaxMuxStreamQueueChunks = 8 | |
| 78 | - | // MaxMuxStreamQueueBytes bounds the inbound bytes queued for one stream: | |
| 79 | - | // 32 MiB. It is only reachable by a caller that hands the engine a chunk | |
| 80 | - | // above the negotiated chunk ceiling, which the codec already refuses. | |
| 81 | - | MaxMuxStreamQueueBytes = 32 << 20 | |
| 82 | - | ||
| 83 | 81 | // MaxRetiredStreamRecords bounds the settled stream records one engine | |
| 84 | 82 | // retains so a late frame is still classified as retired instead of | |
| 85 | 83 | // mistaken for a future stream. It mirrors the negotiated stream ceiling | |
| @@ -109,9 +107,17 @@ var ( | |||
| 109 | 107 | // ErrStreamRefMismatch reports an Opened or Refused whose full stream | |
| 110 | 108 | // reference does not match the one recorded at admission. | |
| 111 | 109 | ErrStreamRefMismatch = errors.New("daemonmux: stream reference mismatch") | |
| 112 | - | // ErrStreamQueueFull reports an inbound chunk that would exceed a queue | |
| 113 | - | // bound. The offending stream is reset and its budget released. | |
| 114 | - | ErrStreamQueueFull = errors.New("daemonmux: stream queue overflow") | |
| 110 | + | // ErrStreamQueueFull reports an inbound chunk the peer sent beyond the | |
| 111 | + | // credit it was granted, or beyond the aggregate budget. It is a peer | |
| 112 | + | // flow-control violation: the offending stream is reset and its budget | |
| 113 | + | // released. | |
| 114 | + | ErrStreamQueueFull = errors.New("daemonmux: stream receive window exceeded") | |
| 115 | + | // ErrStreamCredit reports a WindowUpdate that would grow the send credit | |
| 116 | + | // past the stream window: a peer flow-control violation. | |
| 117 | + | ErrStreamCredit = errors.New("daemonmux: stream credit exceeds window") | |
| 118 | + | // errStreamSettled reports a local send on a stream that already reached | |
| 119 | + | // its terminal state. | |
| 120 | + | errStreamSettled = errors.New("daemonmux: stream is settled") | |
| 115 | 121 | // ErrPhysicalClosed reports admission or stream work presented to a | |
| 116 | 122 | // physical connection that already reached its terminal outcome. | |
| 117 | 123 | ErrPhysicalClosed = errors.New("daemonmux: physical connection is closed") | |
| @@ -190,6 +196,9 @@ type StreamStatus struct { | |||
| 190 | 196 | // and bytes. | |
| 191 | 197 | QueuedChunks int | |
| 192 | 198 | QueuedBytes int | |
| 199 | + | // SendCredit is the remaining outbound credit in credit bytes: payload | |
| 200 | + | // plus MuxChunkCreditOverhead per Data frame (see chunkCredit). | |
| 201 | + | SendCredit uint64 | |
| 193 | 202 | } | |
| 194 | 203 | ||
| 195 | 204 | // muxStream is one tracked logical stream. Every field is guarded by the | |
| @@ -203,14 +212,25 @@ type muxStream struct { | |||
| 203 | 212 | bytes int | |
| 204 | 213 | refused bool | |
| 205 | 214 | refusal ErrorDetail | |
| 215 | + | ||
| 216 | + | // recvUsed is the inbound credit the peer spent that has not been | |
| 217 | + | // returned yet: queued chunks plus consumed chunks awaiting a | |
| 218 | + | // WindowUpdate. It never exceeds the window for a conforming peer. | |
| 219 | + | recvUsed uint64 | |
| 220 | + | // recvReturn is consumed credit not yet granted back to the peer. | |
| 221 | + | recvReturn uint64 | |
| 222 | + | // sendCredit is the outbound credit this side may still spend. | |
| 223 | + | sendCredit uint64 | |
| 224 | + | // creditWake closes and is replaced whenever sendCredit grows or the | |
| 225 | + | // stream settles, so a blocked writer wakes without polling. | |
| 226 | + | creditWake chan struct{} | |
| 206 | 227 | } | |
| 207 | 228 | ||
| 208 | 229 | // StreamEngine is the in-memory stream table and admission authority of one | |
| 209 | 230 | // physical daemon connection. It is safe for concurrent use. It owns no I/O. | |
| 210 | 231 | // The admission, chunk, and aggregate budgets are copied from the negotiated | |
| 211 | - | // MuxCeilings at construction; the per-stream queue bounds are engine-local. | |
| 212 | - | // They are fields so the engine's private ceilings are fixed per instance and | |
| 213 | - | // a test can tighten them. | |
| 232 | + | // MuxCeilings at construction, and the per-stream window is derived from | |
| 233 | + | // them. They are fields so the ceilings are fixed per instance. | |
| 214 | 234 | type StreamEngine struct { | |
| 215 | 235 | mu sync.Mutex | |
| 216 | 236 | physical *terminalState | |
| @@ -222,8 +242,8 @@ type StreamEngine struct { | |||
| 222 | 242 | ||
| 223 | 243 | maxStreams int | |
| 224 | 244 | maxChunkBytes uint64 | |
| 225 | - | maxStreamBytes int | |
| 226 | - | maxStreamFrames int | |
| 245 | + | window uint64 | |
| 246 | + | returnAt uint64 | |
| 227 | 247 | maxAggregateBytes int | |
| 228 | 248 | ||
| 229 | 249 | // restricted, when set, binds every fresh inbound Open to the exact | |
| @@ -255,8 +275,8 @@ func newStreamEngine(ceilings MuxCeilings) *StreamEngine { | |||
| 255 | 275 | streams: make(map[PhysicalStreamID]*muxStream, int(ceilings.MaxStreams)), | |
| 256 | 276 | maxStreams: int(ceilings.MaxStreams), | |
| 257 | 277 | maxChunkBytes: ceilings.StreamChunkLimit, | |
| 258 | - | maxStreamBytes: MaxMuxStreamQueueBytes, | |
| 259 | - | maxStreamFrames: MaxMuxStreamQueueChunks, | |
| 278 | + | window: ceilings.StreamWindow(), | |
| 279 | + | returnAt: ceilings.creditReturnThreshold(), | |
| 260 | 280 | maxAggregateBytes: int(ceilings.MaxAggregateBytes), | |
| 261 | 281 | } | |
| 262 | 282 | } | |
| @@ -383,7 +403,7 @@ func (e *StreamEngine) Open(msg Open) error { | |||
| 383 | 403 | if e.live >= e.maxStreams { | |
| 384 | 404 | return ErrTooManyStreams | |
| 385 | 405 | } | |
| 386 | - | e.streams[id] = &muxStream{ref: msg.Ref, state: StreamOpening, terminal: newTerminalState()} | |
| 406 | + | e.streams[id] = &muxStream{ref: msg.Ref, state: StreamOpening, terminal: newTerminalState(), sendCredit: e.window, creditWake: make(chan struct{})} | |
| 387 | 407 | e.highest = id | |
| 388 | 408 | e.live++ | |
| 389 | 409 | return nil | |
| @@ -445,9 +465,9 @@ func (e *StreamEngine) Refused(msg Refused) error { | |||
| 445 | 465 | // (closing) accepts no new data and refuses it with ErrStreamState, exactly as | |
| 446 | 466 | // an unconfirmed stream refuses data. A chunk above the negotiated chunk | |
| 447 | 467 | // ceiling is refused statelessly with ErrTooLarge and mutates nothing. A chunk | |
| 448 | - | // that would exceed the per-stream chunk, the per-stream byte, or the | |
| 449 | - | // aggregate byte bound resets exactly this stream and releases the bytes it | |
| 450 | - | // held, leaving every sibling untouched. | |
| 468 | + | // beyond the stream's receive window or the aggregate byte bound is a peer | |
| 469 | + | // flow-control violation: it resets exactly this stream and releases the | |
| 470 | + | // bytes it held, leaving every sibling untouched. | |
| 451 | 471 | func (e *StreamEngine) Data(msg Data) (StreamDisposition, error) { | |
| 452 | 472 | if e == nil { | |
| 453 | 473 | return 0, ErrPhysicalClosed | |
| @@ -469,13 +489,100 @@ func (e *StreamEngine) Data(msg Data) (StreamDisposition, error) { | |||
| 469 | 489 | } | |
| 470 | 490 | if e.queueOverflowsLocked(stream, len(msg.Data)) { | |
| 471 | 491 | e.retireLocked(stream) | |
| 472 | - | stream.terminal.Fail(domain.RemoteFailureTransport, ErrStreamQueueFull) | |
| 492 | + | stream.terminal.Fail(domain.RemoteFailureInvalidResponse, ErrStreamQueueFull) | |
| 473 | 493 | return 0, ErrStreamQueueFull | |
| 474 | 494 | } | |
| 475 | 495 | e.enqueueLocked(stream, msg.Data) | |
| 476 | 496 | return StreamAccepted, nil | |
| 477 | 497 | } | |
| 478 | 498 | ||
| 499 | + | // WindowUpdate grows one stream's send credit by the credit the peer returned. | |
| 500 | + | // A late update for a retired stream is discarded. Credit that would exceed | |
| 501 | + | // the stream window is a peer flow-control violation (ErrStreamCredit). | |
| 502 | + | func (e *StreamEngine) WindowUpdate(msg WindowUpdate) (StreamDisposition, error) { | |
| 503 | + | if e == nil { | |
| 504 | + | return 0, ErrPhysicalClosed | |
| 505 | + | } | |
| 506 | + | e.mu.Lock() | |
| 507 | + | defer e.mu.Unlock() | |
| 508 | + | stream, state, err := e.lookupLocked(msg.Physical) | |
| 509 | + | if err != nil { | |
| 510 | + | return 0, err | |
| 511 | + | } | |
| 512 | + | if state == StreamTerminal { | |
| 513 | + | return StreamDiscarded, nil | |
| 514 | + | } | |
| 515 | + | if msg.Credit > e.window-stream.sendCredit { | |
| 516 | + | return 0, ErrStreamCredit | |
| 517 | + | } | |
| 518 | + | stream.sendCredit += msg.Credit | |
| 519 | + | wakeCreditLocked(stream) | |
| 520 | + | return StreamAccepted, nil | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | // reserveSend spends the credit of one outbound Data frame of n bytes. It | |
| 524 | + | // reports ok=false with a wake channel when credit is short; the caller waits | |
| 525 | + | // on it and retries. A closing, terminal, or unknown stream reports | |
| 526 | + | // errStreamSettled: the peer already retired it and grants no more credit. | |
| 527 | + | func (e *StreamEngine) reserveSend(physical PhysicalStreamID, n int) (bool, <-chan struct{}, error) { | |
| 528 | + | if e == nil { | |
| 529 | + | return false, nil, ErrPhysicalClosed | |
| 530 | + | } | |
| 531 | + | cost := chunkCredit(n) | |
| 532 | + | e.mu.Lock() | |
| 533 | + | defer e.mu.Unlock() | |
| 534 | + | stream := e.streams[physical] | |
| 535 | + | if stream == nil || stream.state == StreamTerminal || stream.state == StreamClosing { | |
| 536 | + | return false, nil, errStreamSettled | |
| 537 | + | } | |
| 538 | + | if stream.sendCredit < cost { | |
| 539 | + | return false, stream.creditWake, nil | |
| 540 | + | } | |
| 541 | + | stream.sendCredit -= cost | |
| 542 | + | return true, nil, nil | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | // refundSend returns the credit of one outbound Data frame that was reserved | |
| 546 | + | // but never enqueued. | |
| 547 | + | func (e *StreamEngine) refundSend(physical PhysicalStreamID, n int) { | |
| 548 | + | if e == nil { | |
| 549 | + | return | |
| 550 | + | } | |
| 551 | + | e.mu.Lock() | |
| 552 | + | defer e.mu.Unlock() | |
| 553 | + | if stream := e.streams[physical]; stream != nil && stream.state != StreamTerminal { | |
| 554 | + | stream.sendCredit = min(stream.sendCredit+chunkCredit(n), e.window) | |
| 555 | + | wakeCreditLocked(stream) | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | // takeReturn claims the consumed inbound credit of one stream once it reaches | |
| 560 | + | // the return threshold, so the peer is granted credit in batches instead of | |
| 561 | + | // one WindowUpdate per chunk. It returns zero when nothing is due yet. | |
| 562 | + | func (e *StreamEngine) takeReturn(physical PhysicalStreamID) uint64 { | |
| 563 | + | if e == nil { | |
| 564 | + | return 0 | |
| 565 | + | } | |
| 566 | + | e.mu.Lock() | |
| 567 | + | defer e.mu.Unlock() | |
| 568 | + | stream := e.streams[physical] | |
| 569 | + | if stream == nil || stream.state != StreamOpen || stream.recvReturn < e.returnAt { | |
| 570 | + | return 0 | |
| 571 | + | } | |
| 572 | + | grant := stream.recvReturn | |
| 573 | + | stream.recvReturn = 0 | |
| 574 | + | stream.recvUsed -= grant | |
| 575 | + | return grant | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | // wakeCreditLocked wakes every writer waiting on this stream's credit. | |
| 579 | + | func wakeCreditLocked(stream *muxStream) { | |
| 580 | + | if stream.creditWake != nil { | |
| 581 | + | close(stream.creditWake) | |
| 582 | + | } | |
| 583 | + | stream.creditWake = make(chan struct{}) | |
| 584 | + | } | |
| 585 | + | ||
| 479 | 586 | // Close orderly terminates one opening, open, or closing stream. A late close | |
| 480 | 587 | // for an already terminal or closing stream is discarded. It never | |
| 481 | 588 | // terminalizes the physical connection or a sibling stream. | |
| @@ -504,6 +611,9 @@ func (e *StreamEngine) Close(msg Close) (StreamDisposition, error) { | |||
| 504 | 611 | return StreamAccepted, nil | |
| 505 | 612 | } | |
| 506 | 613 | stream.state = StreamClosing | |
| 614 | + | // The peer retired the stream: a writer waiting on credit must observe it | |
| 615 | + | // instead of waiting for a WindowUpdate that never comes. | |
| 616 | + | wakeCreditLocked(stream) | |
| 507 | 617 | return StreamAccepted, nil | |
| 508 | 618 | } | |
| 509 | 619 | ||
| @@ -586,6 +696,7 @@ func (e *StreamEngine) Status(physical PhysicalStreamID) (StreamStatus, bool) { | |||
| 586 | 696 | Refusal: stream.refusal, | |
| 587 | 697 | QueuedChunks: len(stream.queue), | |
| 588 | 698 | QueuedBytes: stream.bytes, | |
| 699 | + | SendCredit: stream.sendCredit, | |
| 589 | 700 | }, true | |
| 590 | 701 | } | |
| 591 | 702 | ||
| @@ -636,6 +747,7 @@ func (e *StreamEngine) take(physical PhysicalStreamID) ([]byte, bool, bool) { | |||
| 636 | 747 | stream.queue = stream.queue[1:] | |
| 637 | 748 | stream.bytes -= len(chunk) | |
| 638 | 749 | e.aggregate -= len(chunk) | |
| 750 | + | stream.recvReturn += chunkCredit(len(chunk)) | |
| 639 | 751 | if stream.state == StreamClosing && len(stream.queue) == 0 { | |
| 640 | 752 | e.retireLocked(stream) | |
| 641 | 753 | stream.terminal.Close() | |
| @@ -680,6 +792,8 @@ func (e *StreamEngine) terminalizeAllLocked(publish func(*muxStream)) { | |||
| 680 | 792 | func (e *StreamEngine) retireLocked(stream *muxStream) { | |
| 681 | 793 | e.releaseQueueLocked(stream) | |
| 682 | 794 | stream.state = StreamTerminal | |
| 795 | + | stream.sendCredit = 0 | |
| 796 | + | wakeCreditLocked(stream) | |
| 683 | 797 | e.live-- | |
| 684 | 798 | e.retired = append(e.retired, stream.ref.Physical) | |
| 685 | 799 | for len(e.retired) > MaxRetiredStreamRecords { | |
| @@ -705,11 +819,9 @@ func (e *StreamEngine) releaseQueueLocked(stream *muxStream) { | |||
| 705 | 819 | } | |
| 706 | 820 | ||
| 707 | 821 | // queueOverflowsLocked reports whether one inbound chunk would exceed the | |
| 708 | - | // per-stream chunk bound, the per-stream byte bound, or the negotiated | |
| 709 | - | // aggregate byte bound. | |
| 822 | + | // credit granted to the peer or the negotiated aggregate byte bound. | |
| 710 | 823 | func (e *StreamEngine) queueOverflowsLocked(stream *muxStream, n int) bool { | |
| 711 | - | return len(stream.queue) >= e.maxStreamFrames || | |
| 712 | - | stream.bytes+n > e.maxStreamBytes || | |
| 824 | + | return stream.recvUsed+chunkCredit(n) > e.window || | |
| 713 | 825 | e.aggregate+n > e.maxAggregateBytes | |
| 714 | 826 | } | |
| 715 | 827 | ||
| @@ -719,6 +831,7 @@ func (e *StreamEngine) enqueueLocked(stream *muxStream, chunk []byte) { | |||
| 719 | 831 | n := len(chunk) | |
| 720 | 832 | e.aggregate += n | |
| 721 | 833 | stream.bytes += n | |
| 834 | + | stream.recvUsed += chunkCredit(n) | |
| 722 | 835 | owned := make([]byte, n) | |
| 723 | 836 | copy(owned, chunk) | |
| 724 | 837 | stream.queue = append(stream.queue, owned) | |
| @@ -33,17 +33,22 @@ | |||||
| 33 | // that always discards the queue immediately. Neither Close nor Reset ever | 33 | // that always discards the queue immediately. Neither Close nor Reset ever | ||
| 34 | // terminalizes the physical connection or a sibling stream. | 34 | // terminalizes the physical connection or a sibling stream. | ||
| 35 | // | 35 | // | ||
| 36 | - | // The inbound queue is bounded, and bytes are charged against every budget | 36 | + | // Every stream is credit flow controlled in both directions. Each side starts |
| 37 | - | // before the chunk is copied into the queue: at most MaxMuxStreamQueueChunks | 37 | + | // with the same per-stream window (MuxCeilings.StreamWindow) of send credit; a |
| 38 | - | // chunks and MaxMuxStreamQueueBytes bytes per stream, the negotiated aggregate | 38 | + | // Data frame costs its payload plus a fixed overhead (chunkCredit), and the |
| 39 | - | // ceiling (MuxCeilings.MaxAggregateBytes) across the connection, and the | 39 | + | // receiver returns credit with WindowUpdate as its consumer takes chunks. A |
| 40 | - | // negotiated chunk ceiling (MuxCeilings.StreamChunkLimit) per chunk. A chunk | 40 | + | // conforming sender therefore waits for credit instead of overrunning the |
| 41 | - | // above the negotiated chunk ceiling is refused statelessly with ErrTooLarge | 41 | + | // receiver, so a slow network or a slow consumer only slows the stream. |
| 42 | - | // and mutates nothing, exactly as the codec refuses it. A chunk that would | 42 | + | // |
| 43 | - | // exceed a queue bound resets exactly that stream and releases the bytes it | 43 | + | // The inbound queue is bounded by that same window, and bytes are charged |
| 44 | - | // held; sibling streams keep their budget and keep progressing. The per-stream | 44 | + | // against every budget before the chunk is copied into the queue: the stream |
| 45 | - | // byte bound is defensive: the codec already refuses a chunk above the | 45 | + | // window per stream, the negotiated aggregate ceiling |
| 46 | - | // negotiated chunk ceiling, so a conforming caller never reaches it. | 46 | + | // (MuxCeilings.MaxAggregateBytes) across the connection, and the negotiated |
| 47 | + | // chunk ceiling (MuxCeilings.StreamChunkLimit) per chunk. A chunk above the | |||
| 48 | + | // negotiated chunk ceiling is refused statelessly with ErrTooLarge and mutates | |||
| 49 | + | // nothing, exactly as the codec refuses it. A chunk beyond the window is a | |||
| 50 | + | // peer flow-control violation: it resets exactly that stream and releases the | |||
| 51 | + | // bytes it held; sibling streams keep their budget and keep progressing. | |||
| 47 | // | 52 | // | ||
| 48 | // Terminal publication is deliberately ordered: a physical terminal outcome | 53 | // Terminal publication is deliberately ordered: a physical terminal outcome | ||
| 49 | // publishes its own Done first and only then terminalizes every live stream | 54 | // publishes its own Done first and only then terminalizes every live stream | ||
| @@ -71,15 +76,8 @@ import ( | |||||
| 71 | 76 | ||||
| 72 | // Stream queue bounds. The stream-count and aggregate-bytes ceilings are the | 77 | // Stream queue bounds. The stream-count and aggregate-bytes ceilings are the | ||
| 73 | // negotiated maxima from limits.go (MaxMuxStreams, MaxMuxAggregateBytes); the | 78 | // negotiated maxima from limits.go (MaxMuxStreams, MaxMuxAggregateBytes); the | ||
| 74 | - | // per-stream chunk and byte ceilings are engine-local. | 79 | + | // per-stream bound is the flow-control window (MuxCeilings.StreamWindow). |
| 75 | const ( | 80 | const ( | ||
| 76 | - | // MaxMuxStreamQueueChunks bounds the inbound chunks queued for one stream. | |||
| 77 | - | MaxMuxStreamQueueChunks = 8 | |||
| 78 | - | // MaxMuxStreamQueueBytes bounds the inbound bytes queued for one stream: | |||
| 79 | - | // 32 MiB. It is only reachable by a caller that hands the engine a chunk | |||
| 80 | - | // above the negotiated chunk ceiling, which the codec already refuses. | |||
| 81 | - | MaxMuxStreamQueueBytes = 32 << 20 | |||
| 82 | - | ||||
| 83 | // MaxRetiredStreamRecords bounds the settled stream records one engine | 81 | // MaxRetiredStreamRecords bounds the settled stream records one engine | ||
| 84 | // retains so a late frame is still classified as retired instead of | 82 | // retains so a late frame is still classified as retired instead of | ||
| 85 | // mistaken for a future stream. It mirrors the negotiated stream ceiling | 83 | // mistaken for a future stream. It mirrors the negotiated stream ceiling | ||
| @@ -109,9 +107,17 @@ var ( | |||||
| 109 | // ErrStreamRefMismatch reports an Opened or Refused whose full stream | 107 | // ErrStreamRefMismatch reports an Opened or Refused whose full stream | ||
| 110 | // reference does not match the one recorded at admission. | 108 | // reference does not match the one recorded at admission. | ||
| 111 | ErrStreamRefMismatch = errors.New("daemonmux: stream reference mismatch") | 109 | ErrStreamRefMismatch = errors.New("daemonmux: stream reference mismatch") | ||
| 112 | - | // ErrStreamQueueFull reports an inbound chunk that would exceed a queue | 110 | + | // ErrStreamQueueFull reports an inbound chunk the peer sent beyond the |
| 113 | - | // bound. The offending stream is reset and its budget released. | 111 | + | // credit it was granted, or beyond the aggregate budget. It is a peer |
| 114 | - | ErrStreamQueueFull = errors.New("daemonmux: stream queue overflow") | 112 | + | // flow-control violation: the offending stream is reset and its budget |
| 113 | + | // released. | |||
| 114 | + | ErrStreamQueueFull = errors.New("daemonmux: stream receive window exceeded") | |||
| 115 | + | // ErrStreamCredit reports a WindowUpdate that would grow the send credit | |||
| 116 | + | // past the stream window: a peer flow-control violation. | |||
| 117 | + | ErrStreamCredit = errors.New("daemonmux: stream credit exceeds window") | |||
| 118 | + | // errStreamSettled reports a local send on a stream that already reached | |||
| 119 | + | // its terminal state. | |||
| 120 | + | errStreamSettled = errors.New("daemonmux: stream is settled") | |||
| 115 | // ErrPhysicalClosed reports admission or stream work presented to a | 121 | // ErrPhysicalClosed reports admission or stream work presented to a | ||
| 116 | // physical connection that already reached its terminal outcome. | 122 | // physical connection that already reached its terminal outcome. | ||
| 117 | ErrPhysicalClosed = errors.New("daemonmux: physical connection is closed") | 123 | ErrPhysicalClosed = errors.New("daemonmux: physical connection is closed") | ||
| @@ -190,6 +196,9 @@ type StreamStatus struct { | |||||
| 190 | // and bytes. | 196 | // and bytes. | ||
| 191 | QueuedChunks int | 197 | QueuedChunks int | ||
| 192 | QueuedBytes int | 198 | QueuedBytes int | ||
| 199 | + | // SendCredit is the remaining outbound credit in credit bytes: payload | |||
| 200 | + | // plus MuxChunkCreditOverhead per Data frame (see chunkCredit). | |||
| 201 | + | SendCredit uint64 | |||
| 193 | } | 202 | } | ||
| 194 | 203 | ||||
| 195 | // muxStream is one tracked logical stream. Every field is guarded by the | 204 | // muxStream is one tracked logical stream. Every field is guarded by the | ||
| @@ -203,14 +212,25 @@ type muxStream struct { | |||||
| 203 | bytes int | 212 | bytes int | ||
| 204 | refused bool | 213 | refused bool | ||
| 205 | refusal ErrorDetail | 214 | refusal ErrorDetail | ||
| 215 | + | ||||
| 216 | + | // recvUsed is the inbound credit the peer spent that has not been | |||
| 217 | + | // returned yet: queued chunks plus consumed chunks awaiting a | |||
| 218 | + | // WindowUpdate. It never exceeds the window for a conforming peer. | |||
| 219 | + | recvUsed uint64 | |||
| 220 | + | // recvReturn is consumed credit not yet granted back to the peer. | |||
| 221 | + | recvReturn uint64 | |||
| 222 | + | // sendCredit is the outbound credit this side may still spend. | |||
| 223 | + | sendCredit uint64 | |||
| 224 | + | // creditWake closes and is replaced whenever sendCredit grows or the | |||
| 225 | + | // stream settles, so a blocked writer wakes without polling. | |||
| 226 | + | creditWake chan struct{} | |||
| 206 | } | 227 | } | ||
| 207 | 228 | ||||
| 208 | // StreamEngine is the in-memory stream table and admission authority of one | 229 | // StreamEngine is the in-memory stream table and admission authority of one | ||
| 209 | // physical daemon connection. It is safe for concurrent use. It owns no I/O. | 230 | // physical daemon connection. It is safe for concurrent use. It owns no I/O. | ||
| 210 | // The admission, chunk, and aggregate budgets are copied from the negotiated | 231 | // The admission, chunk, and aggregate budgets are copied from the negotiated | ||
| 211 | - | // MuxCeilings at construction; the per-stream queue bounds are engine-local. | 232 | + | // MuxCeilings at construction, and the per-stream window is derived from |
| 212 | - | // They are fields so the engine's private ceilings are fixed per instance and | 233 | + | // them. They are fields so the ceilings are fixed per instance. |
| 213 | - | // a test can tighten them. | |||
| 214 | type StreamEngine struct { | 234 | type StreamEngine struct { | ||
| 215 | mu sync.Mutex | 235 | mu sync.Mutex | ||
| 216 | physical *terminalState | 236 | physical *terminalState | ||
| @@ -222,8 +242,8 @@ type StreamEngine struct { | |||||
| 222 | 242 | ||||
| 223 | maxStreams int | 243 | maxStreams int | ||
| 224 | maxChunkBytes uint64 | 244 | maxChunkBytes uint64 | ||
| 225 | - | maxStreamBytes int | 245 | + | window uint64 |
| 226 | - | maxStreamFrames int | 246 | + | returnAt uint64 |
| 227 | maxAggregateBytes int | 247 | maxAggregateBytes int | ||
| 228 | 248 | ||||
| 229 | // restricted, when set, binds every fresh inbound Open to the exact | 249 | // restricted, when set, binds every fresh inbound Open to the exact | ||
| @@ -255,8 +275,8 @@ func newStreamEngine(ceilings MuxCeilings) *StreamEngine { | |||||
| 255 | streams: make(map[PhysicalStreamID]*muxStream, int(ceilings.MaxStreams)), | 275 | streams: make(map[PhysicalStreamID]*muxStream, int(ceilings.MaxStreams)), | ||
| 256 | maxStreams: int(ceilings.MaxStreams), | 276 | maxStreams: int(ceilings.MaxStreams), | ||
| 257 | maxChunkBytes: ceilings.StreamChunkLimit, | 277 | maxChunkBytes: ceilings.StreamChunkLimit, | ||
| 258 | - | maxStreamBytes: MaxMuxStreamQueueBytes, | 278 | + | window: ceilings.StreamWindow(), |
| 259 | - | maxStreamFrames: MaxMuxStreamQueueChunks, | 279 | + | returnAt: ceilings.creditReturnThreshold(), |
| 260 | maxAggregateBytes: int(ceilings.MaxAggregateBytes), | 280 | maxAggregateBytes: int(ceilings.MaxAggregateBytes), | ||
| 261 | } | 281 | } | ||
| 262 | } | 282 | } | ||
| @@ -383,7 +403,7 @@ func (e *StreamEngine) Open(msg Open) error { | |||||
| 383 | if e.live >= e.maxStreams { | 403 | if e.live >= e.maxStreams { | ||
| 384 | return ErrTooManyStreams | 404 | return ErrTooManyStreams | ||
| 385 | } | 405 | } | ||
| 386 | - | e.streams[id] = &muxStream{ref: msg.Ref, state: StreamOpening, terminal: newTerminalState()} | 406 | + | e.streams[id] = &muxStream{ref: msg.Ref, state: StreamOpening, terminal: newTerminalState(), sendCredit: e.window, creditWake: make(chan struct{})} |
| 387 | e.highest = id | 407 | e.highest = id | ||
| 388 | e.live++ | 408 | e.live++ | ||
| 389 | return nil | 409 | return nil | ||
| @@ -445,9 +465,9 @@ func (e *StreamEngine) Refused(msg Refused) error { | |||||
| 445 | // (closing) accepts no new data and refuses it with ErrStreamState, exactly as | 465 | // (closing) accepts no new data and refuses it with ErrStreamState, exactly as | ||
| 446 | // an unconfirmed stream refuses data. A chunk above the negotiated chunk | 466 | // an unconfirmed stream refuses data. A chunk above the negotiated chunk | ||
| 447 | // ceiling is refused statelessly with ErrTooLarge and mutates nothing. A chunk | 467 | // ceiling is refused statelessly with ErrTooLarge and mutates nothing. A chunk | ||
| 448 | - | // that would exceed the per-stream chunk, the per-stream byte, or the | 468 | + | // beyond the stream's receive window or the aggregate byte bound is a peer |
| 449 | - | // aggregate byte bound resets exactly this stream and releases the bytes it | 469 | + | // flow-control violation: it resets exactly this stream and releases the |
| 450 | - | // held, leaving every sibling untouched. | 470 | + | // bytes it held, leaving every sibling untouched. |
| 451 | func (e *StreamEngine) Data(msg Data) (StreamDisposition, error) { | 471 | func (e *StreamEngine) Data(msg Data) (StreamDisposition, error) { | ||
| 452 | if e == nil { | 472 | if e == nil { | ||
| 453 | return 0, ErrPhysicalClosed | 473 | return 0, ErrPhysicalClosed | ||
| @@ -469,13 +489,100 @@ func (e *StreamEngine) Data(msg Data) (StreamDisposition, error) { | |||||
| 469 | } | 489 | } | ||
| 470 | if e.queueOverflowsLocked(stream, len(msg.Data)) { | 490 | if e.queueOverflowsLocked(stream, len(msg.Data)) { | ||
| 471 | e.retireLocked(stream) | 491 | e.retireLocked(stream) | ||
| 472 | - | stream.terminal.Fail(domain.RemoteFailureTransport, ErrStreamQueueFull) | 492 | + | stream.terminal.Fail(domain.RemoteFailureInvalidResponse, ErrStreamQueueFull) |
| 473 | return 0, ErrStreamQueueFull | 493 | return 0, ErrStreamQueueFull | ||
| 474 | } | 494 | } | ||
| 475 | e.enqueueLocked(stream, msg.Data) | 495 | e.enqueueLocked(stream, msg.Data) | ||
| 476 | return StreamAccepted, nil | 496 | return StreamAccepted, nil | ||
| 477 | } | 497 | } | ||
| 478 | 498 | ||||
| 499 | + | // WindowUpdate grows one stream's send credit by the credit the peer returned. | |||
| 500 | + | // A late update for a retired stream is discarded. Credit that would exceed | |||
| 501 | + | // the stream window is a peer flow-control violation (ErrStreamCredit). | |||
| 502 | + | func (e *StreamEngine) WindowUpdate(msg WindowUpdate) (StreamDisposition, error) { | |||
| 503 | + | if e == nil { | |||
| 504 | + | return 0, ErrPhysicalClosed | |||
| 505 | + | } | |||
| 506 | + | e.mu.Lock() | |||
| 507 | + | defer e.mu.Unlock() | |||
| 508 | + | stream, state, err := e.lookupLocked(msg.Physical) | |||
| 509 | + | if err != nil { | |||
| 510 | + | return 0, err | |||
| 511 | + | } | |||
| 512 | + | if state == StreamTerminal { | |||
| 513 | + | return StreamDiscarded, nil | |||
| 514 | + | } | |||
| 515 | + | if msg.Credit > e.window-stream.sendCredit { | |||
| 516 | + | return 0, ErrStreamCredit | |||
| 517 | + | } | |||
| 518 | + | stream.sendCredit += msg.Credit | |||
| 519 | + | wakeCreditLocked(stream) | |||
| 520 | + | return StreamAccepted, nil | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | // reserveSend spends the credit of one outbound Data frame of n bytes. It | |||
| 524 | + | // reports ok=false with a wake channel when credit is short; the caller waits | |||
| 525 | + | // on it and retries. A closing, terminal, or unknown stream reports | |||
| 526 | + | // errStreamSettled: the peer already retired it and grants no more credit. | |||
| 527 | + | func (e *StreamEngine) reserveSend(physical PhysicalStreamID, n int) (bool, <-chan struct{}, error) { | |||
| 528 | + | if e == nil { | |||
| 529 | + | return false, nil, ErrPhysicalClosed | |||
| 530 | + | } | |||
| 531 | + | cost := chunkCredit(n) | |||
| 532 | + | e.mu.Lock() | |||
| 533 | + | defer e.mu.Unlock() | |||
| 534 | + | stream := e.streams[physical] | |||
| 535 | + | if stream == nil || stream.state == StreamTerminal || stream.state == StreamClosing { | |||
| 536 | + | return false, nil, errStreamSettled | |||
| 537 | + | } | |||
| 538 | + | if stream.sendCredit < cost { | |||
| 539 | + | return false, stream.creditWake, nil | |||
| 540 | + | } | |||
| 541 | + | stream.sendCredit -= cost | |||
| 542 | + | return true, nil, nil | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | // refundSend returns the credit of one outbound Data frame that was reserved | |||
| 546 | + | // but never enqueued. | |||
| 547 | + | func (e *StreamEngine) refundSend(physical PhysicalStreamID, n int) { | |||
| 548 | + | if e == nil { | |||
| 549 | + | return | |||
| 550 | + | } | |||
| 551 | + | e.mu.Lock() | |||
| 552 | + | defer e.mu.Unlock() | |||
| 553 | + | if stream := e.streams[physical]; stream != nil && stream.state != StreamTerminal { | |||
| 554 | + | stream.sendCredit = min(stream.sendCredit+chunkCredit(n), e.window) | |||
| 555 | + | wakeCreditLocked(stream) | |||
| 556 | + | } | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | // takeReturn claims the consumed inbound credit of one stream once it reaches | |||
| 560 | + | // the return threshold, so the peer is granted credit in batches instead of | |||
| 561 | + | // one WindowUpdate per chunk. It returns zero when nothing is due yet. | |||
| 562 | + | func (e *StreamEngine) takeReturn(physical PhysicalStreamID) uint64 { | |||
| 563 | + | if e == nil { | |||
| 564 | + | return 0 | |||
| 565 | + | } | |||
| 566 | + | e.mu.Lock() | |||
| 567 | + | defer e.mu.Unlock() | |||
| 568 | + | stream := e.streams[physical] | |||
| 569 | + | if stream == nil || stream.state != StreamOpen || stream.recvReturn < e.returnAt { | |||
| 570 | + | return 0 | |||
| 571 | + | } | |||
| 572 | + | grant := stream.recvReturn | |||
| 573 | + | stream.recvReturn = 0 | |||
| 574 | + | stream.recvUsed -= grant | |||
| 575 | + | return grant | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | // wakeCreditLocked wakes every writer waiting on this stream's credit. | |||
| 579 | + | func wakeCreditLocked(stream *muxStream) { | |||
| 580 | + | if stream.creditWake != nil { | |||
| 581 | + | close(stream.creditWake) | |||
| 582 | + | } | |||
| 583 | + | stream.creditWake = make(chan struct{}) | |||
| 584 | + | } | |||
| 585 | + | ||||
| 479 | // Close orderly terminates one opening, open, or closing stream. A late close | 586 | // Close orderly terminates one opening, open, or closing stream. A late close | ||
| 480 | // for an already terminal or closing stream is discarded. It never | 587 | // for an already terminal or closing stream is discarded. It never | ||
| 481 | // terminalizes the physical connection or a sibling stream. | 588 | // terminalizes the physical connection or a sibling stream. | ||
| @@ -504,6 +611,9 @@ func (e *StreamEngine) Close(msg Close) (StreamDisposition, error) { | |||||
| 504 | return StreamAccepted, nil | 611 | return StreamAccepted, nil | ||
| 505 | } | 612 | } | ||
| 506 | stream.state = StreamClosing | 613 | stream.state = StreamClosing | ||
| 614 | + | // The peer retired the stream: a writer waiting on credit must observe it | |||
| 615 | + | // instead of waiting for a WindowUpdate that never comes. | |||
| 616 | + | wakeCreditLocked(stream) | |||
| 507 | return StreamAccepted, nil | 617 | return StreamAccepted, nil | ||
| 508 | } | 618 | } | ||
| 509 | 619 | ||||
| @@ -586,6 +696,7 @@ func (e *StreamEngine) Status(physical PhysicalStreamID) (StreamStatus, bool) { | |||||
| 586 | Refusal: stream.refusal, | 696 | Refusal: stream.refusal, | ||
| 587 | QueuedChunks: len(stream.queue), | 697 | QueuedChunks: len(stream.queue), | ||
| 588 | QueuedBytes: stream.bytes, | 698 | QueuedBytes: stream.bytes, | ||
| 699 | + | SendCredit: stream.sendCredit, | |||
| 589 | }, true | 700 | }, true | ||
| 590 | } | 701 | } | ||
| 591 | 702 | ||||
| @@ -636,6 +747,7 @@ func (e *StreamEngine) take(physical PhysicalStreamID) ([]byte, bool, bool) { | |||||
| 636 | stream.queue = stream.queue[1:] | 747 | stream.queue = stream.queue[1:] | ||
| 637 | stream.bytes -= len(chunk) | 748 | stream.bytes -= len(chunk) | ||
| 638 | e.aggregate -= len(chunk) | 749 | e.aggregate -= len(chunk) | ||
| 750 | + | stream.recvReturn += chunkCredit(len(chunk)) | |||
| 639 | if stream.state == StreamClosing && len(stream.queue) == 0 { | 751 | if stream.state == StreamClosing && len(stream.queue) == 0 { | ||
| 640 | e.retireLocked(stream) | 752 | e.retireLocked(stream) | ||
| 641 | stream.terminal.Close() | 753 | stream.terminal.Close() | ||
| @@ -680,6 +792,8 @@ func (e *StreamEngine) terminalizeAllLocked(publish func(*muxStream)) { | |||||
| 680 | func (e *StreamEngine) retireLocked(stream *muxStream) { | 792 | func (e *StreamEngine) retireLocked(stream *muxStream) { | ||
| 681 | e.releaseQueueLocked(stream) | 793 | e.releaseQueueLocked(stream) | ||
| 682 | stream.state = StreamTerminal | 794 | stream.state = StreamTerminal | ||
| 795 | + | stream.sendCredit = 0 | |||
| 796 | + | wakeCreditLocked(stream) | |||
| 683 | e.live-- | 797 | e.live-- | ||
| 684 | e.retired = append(e.retired, stream.ref.Physical) | 798 | e.retired = append(e.retired, stream.ref.Physical) | ||
| 685 | for len(e.retired) > MaxRetiredStreamRecords { | 799 | for len(e.retired) > MaxRetiredStreamRecords { | ||
| @@ -705,11 +819,9 @@ func (e *StreamEngine) releaseQueueLocked(stream *muxStream) { | |||||
| 705 | } | 819 | } | ||
| 706 | 820 | ||||
| 707 | // queueOverflowsLocked reports whether one inbound chunk would exceed the | 821 | // queueOverflowsLocked reports whether one inbound chunk would exceed the | ||
| 708 | - | // per-stream chunk bound, the per-stream byte bound, or the negotiated | 822 | + | // credit granted to the peer or the negotiated aggregate byte bound. |
| 709 | - | // aggregate byte bound. | |||
| 710 | func (e *StreamEngine) queueOverflowsLocked(stream *muxStream, n int) bool { | 823 | func (e *StreamEngine) queueOverflowsLocked(stream *muxStream, n int) bool { | ||
| 711 | - | return len(stream.queue) >= e.maxStreamFrames || | 824 | + | return stream.recvUsed+chunkCredit(n) > e.window || |
| 712 | - | stream.bytes+n > e.maxStreamBytes || | |||
| 713 | e.aggregate+n > e.maxAggregateBytes | 825 | e.aggregate+n > e.maxAggregateBytes | ||
| 714 | } | 826 | } | ||
| 715 | 827 | ||||
| @@ -719,6 +831,7 @@ func (e *StreamEngine) enqueueLocked(stream *muxStream, chunk []byte) { | |||||
| 719 | n := len(chunk) | 831 | n := len(chunk) | ||
| 720 | e.aggregate += n | 832 | e.aggregate += n | ||
| 721 | stream.bytes += n | 833 | stream.bytes += n | ||
| 834 | + | stream.recvUsed += chunkCredit(n) | |||
| 722 | owned := make([]byte, n) | 835 | owned := make([]byte, n) | ||
| 723 | copy(owned, chunk) | 836 | copy(owned, chunk) | ||
| 724 | stream.queue = append(stream.queue, owned) | 837 | stream.queue = append(stream.queue, owned) | ||
M internal/adapters/daemonmux/engine_state_test.go +23 -33
| @@ -480,8 +480,17 @@ func TestStreamEngineIsolation(t *testing.T) { | |||
| 480 | 480 | require.Equal(t, 2, e.AggregateBytes()) | |
| 481 | 481 | } | |
| 482 | 482 | ||
| 483 | - | // TestStreamEngineQueueOverflow proves a stalled queue resets exactly its own | |
| 484 | - | // stream, frees that stream's budget, and leaves a sibling progressing. | |
| 483 | + | // floodWindow returns a maximum chunk and how many of them exactly fill one | |
| 484 | + | // stream's receive window under ceilings: one more is a peer flow-control | |
| 485 | + | // violation. | |
| 486 | + | func floodWindow(ceilings MuxCeilings) ([]byte, int) { | |
| 487 | + | chunk := make([]byte, ceilings.StreamChunkLimit) | |
| 488 | + | return chunk, int(ceilings.StreamWindow() / chunkCredit(len(chunk))) | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // TestStreamEngineQueueOverflow proves a peer that sends past its granted | |
| 492 | + | // window resets exactly its own stream, frees that stream's budget, and leaves | |
| 493 | + | // a sibling progressing. | |
| 485 | 494 | func TestStreamEngineQueueOverflow(t *testing.T) { | |
| 486 | 495 | e := testEngine() | |
| 487 | 496 | openTestStream(t, e, 1) | |
| @@ -489,14 +498,15 @@ func TestStreamEngineQueueOverflow(t *testing.T) { | |||
| 489 | 498 | _, err := e.Data(Data{Physical: 2, Data: []byte("sibling")}) | |
| 490 | 499 | require.NoError(t, err) | |
| 491 | 500 | ||
| 492 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | |
| 493 | - | disposition, err := e.Data(Data{Physical: 1, Data: []byte{byte(i)}}) | |
| 501 | + | chunk, full := floodWindow(DefaultMuxCeilings()) | |
| 502 | + | for i := 0; i < full; i++ { | |
| 503 | + | disposition, err := e.Data(Data{Physical: 1, Data: chunk}) | |
| 494 | 504 | require.NoError(t, err) | |
| 495 | 505 | require.Equal(t, StreamAccepted, disposition) | |
| 496 | 506 | } | |
| 497 | - | require.Equal(t, MaxMuxStreamQueueChunks, mustStatus(t, e, 1).QueuedChunks) | |
| 507 | + | require.Equal(t, full, mustStatus(t, e, 1).QueuedChunks) | |
| 498 | 508 | ||
| 499 | - | disposition, err := e.Data(Data{Physical: 1, Data: []byte{'x'}}) | |
| 509 | + | disposition, err := e.Data(Data{Physical: 1, Data: chunk}) | |
| 500 | 510 | require.ErrorIs(t, err, ErrStreamQueueFull) | |
| 501 | 511 | require.Zero(t, disposition) | |
| 502 | 512 | ||
| @@ -504,7 +514,7 @@ func TestStreamEngineQueueOverflow(t *testing.T) { | |||
| 504 | 514 | require.Equal(t, StreamTerminal, status.State) | |
| 505 | 515 | require.True(t, channelClosed(status.Done)) | |
| 506 | 516 | require.ErrorIs(t, status.Err, ErrStreamQueueFull) | |
| 507 | - | require.Equal(t, domain.RemoteFailureTransport, status.FailureKind) | |
| 517 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, status.FailureKind) | |
| 508 | 518 | require.Zero(t, status.QueuedChunks) | |
| 509 | 519 | require.Zero(t, status.QueuedBytes) | |
| 510 | 520 | require.Equal(t, len("sibling"), e.AggregateBytes(), "the overflowed stream released its budget") | |
| @@ -527,36 +537,16 @@ func TestStreamEngineQueueOverflow(t *testing.T) { | |||
| 527 | 537 | require.ErrorIs(t, mustStatus(t, e, 1).Err, ErrStreamQueueFull) | |
| 528 | 538 | } | |
| 529 | 539 | ||
| 530 | - | // TestStreamEngineQueueByteBounds proves the per-stream and aggregate byte | |
| 531 | - | // ceilings reset only the offending stream. | |
| 540 | + | // TestStreamEngineQueueByteBounds proves the aggregate byte ceiling resets | |
| 541 | + | // only the offending stream. | |
| 532 | 542 | func TestStreamEngineQueueByteBounds(t *testing.T) { | |
| 533 | - | t.Run("per-stream byte bound", func(t *testing.T) { | |
| 534 | - | e := testEngine() | |
| 535 | - | // The codec never admits a chunk above the negotiated chunk ceiling; | |
| 536 | - | // raise the engine's private ceiling so this test can reach the | |
| 537 | - | // defensive per-stream byte bound directly. | |
| 538 | - | e.maxChunkBytes = MaxMuxStreamQueueBytes | |
| 539 | - | openTestStream(t, e, 1) | |
| 540 | - | chunk := make([]byte, MaxMuxStreamQueueBytes/2+1) | |
| 541 | - | disposition, err := e.Data(Data{Physical: 1, Data: chunk}) | |
| 542 | - | require.NoError(t, err) | |
| 543 | - | require.Equal(t, StreamAccepted, disposition) | |
| 544 | - | require.Equal(t, len(chunk), e.AggregateBytes()) | |
| 545 | - | ||
| 546 | - | // The second chunk fits the chunk-count and aggregate bounds but | |
| 547 | - | // exceeds the per-stream byte bound. | |
| 548 | - | disposition, err = e.Data(Data{Physical: 1, Data: chunk}) | |
| 549 | - | require.ErrorIs(t, err, ErrStreamQueueFull) | |
| 550 | - | require.Zero(t, disposition) | |
| 551 | - | require.Zero(t, e.AggregateBytes(), "the reset stream released its bytes") | |
| 552 | - | require.Equal(t, StreamTerminal, mustStatus(t, e, 1).State) | |
| 553 | - | }) | |
| 554 | - | ||
| 555 | 543 | t.Run("aggregate byte bound", func(t *testing.T) { | |
| 556 | 544 | e := testEngine() | |
| 557 | - | // As above: bypass the codec-level chunk ceiling to reach the | |
| 558 | - | // defensive aggregate byte bound with oversized chunks. | |
| 545 | + | // Bypass the codec-level chunk ceiling and widen the window so this | |
| 546 | + | // test reaches the defensive aggregate byte bound with oversized | |
| 547 | + | // chunks. | |
| 559 | 548 | e.maxChunkBytes = 16 << 20 | |
| 549 | + | e.window = 32 << 20 | |
| 560 | 550 | chunk := make([]byte, 16<<20) | |
| 561 | 551 | for i := 1; i <= 4; i++ { | |
| 562 | 552 | openTestStream(t, e, PhysicalStreamID(i)) | |
| @@ -480,8 +480,17 @@ func TestStreamEngineIsolation(t *testing.T) { | |||||
| 480 | require.Equal(t, 2, e.AggregateBytes()) | 480 | require.Equal(t, 2, e.AggregateBytes()) | ||
| 481 | } | 481 | } | ||
| 482 | 482 | ||||
| 483 | - | // TestStreamEngineQueueOverflow proves a stalled queue resets exactly its own | 483 | + | // floodWindow returns a maximum chunk and how many of them exactly fill one |
| 484 | - | // stream, frees that stream's budget, and leaves a sibling progressing. | 484 | + | // stream's receive window under ceilings: one more is a peer flow-control |
| 485 | + | // violation. | |||
| 486 | + | func floodWindow(ceilings MuxCeilings) ([]byte, int) { | |||
| 487 | + | chunk := make([]byte, ceilings.StreamChunkLimit) | |||
| 488 | + | return chunk, int(ceilings.StreamWindow() / chunkCredit(len(chunk))) | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // TestStreamEngineQueueOverflow proves a peer that sends past its granted | |||
| 492 | + | // window resets exactly its own stream, frees that stream's budget, and leaves | |||
| 493 | + | // a sibling progressing. | |||
| 485 | func TestStreamEngineQueueOverflow(t *testing.T) { | 494 | func TestStreamEngineQueueOverflow(t *testing.T) { | ||
| 486 | e := testEngine() | 495 | e := testEngine() | ||
| 487 | openTestStream(t, e, 1) | 496 | openTestStream(t, e, 1) | ||
| @@ -489,14 +498,15 @@ func TestStreamEngineQueueOverflow(t *testing.T) { | |||||
| 489 | _, err := e.Data(Data{Physical: 2, Data: []byte("sibling")}) | 498 | _, err := e.Data(Data{Physical: 2, Data: []byte("sibling")}) | ||
| 490 | require.NoError(t, err) | 499 | require.NoError(t, err) | ||
| 491 | 500 | ||||
| 492 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | 501 | + | chunk, full := floodWindow(DefaultMuxCeilings()) |
| 493 | - | disposition, err := e.Data(Data{Physical: 1, Data: []byte{byte(i)}}) | 502 | + | for i := 0; i < full; i++ { |
| 503 | + | disposition, err := e.Data(Data{Physical: 1, Data: chunk}) | |||
| 494 | require.NoError(t, err) | 504 | require.NoError(t, err) | ||
| 495 | require.Equal(t, StreamAccepted, disposition) | 505 | require.Equal(t, StreamAccepted, disposition) | ||
| 496 | } | 506 | } | ||
| 497 | - | require.Equal(t, MaxMuxStreamQueueChunks, mustStatus(t, e, 1).QueuedChunks) | 507 | + | require.Equal(t, full, mustStatus(t, e, 1).QueuedChunks) |
| 498 | 508 | ||||
| 499 | - | disposition, err := e.Data(Data{Physical: 1, Data: []byte{'x'}}) | 509 | + | disposition, err := e.Data(Data{Physical: 1, Data: chunk}) |
| 500 | require.ErrorIs(t, err, ErrStreamQueueFull) | 510 | require.ErrorIs(t, err, ErrStreamQueueFull) | ||
| 501 | require.Zero(t, disposition) | 511 | require.Zero(t, disposition) | ||
| 502 | 512 | ||||
| @@ -504,7 +514,7 @@ func TestStreamEngineQueueOverflow(t *testing.T) { | |||||
| 504 | require.Equal(t, StreamTerminal, status.State) | 514 | require.Equal(t, StreamTerminal, status.State) | ||
| 505 | require.True(t, channelClosed(status.Done)) | 515 | require.True(t, channelClosed(status.Done)) | ||
| 506 | require.ErrorIs(t, status.Err, ErrStreamQueueFull) | 516 | require.ErrorIs(t, status.Err, ErrStreamQueueFull) | ||
| 507 | - | require.Equal(t, domain.RemoteFailureTransport, status.FailureKind) | 517 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, status.FailureKind) |
| 508 | require.Zero(t, status.QueuedChunks) | 518 | require.Zero(t, status.QueuedChunks) | ||
| 509 | require.Zero(t, status.QueuedBytes) | 519 | require.Zero(t, status.QueuedBytes) | ||
| 510 | require.Equal(t, len("sibling"), e.AggregateBytes(), "the overflowed stream released its budget") | 520 | require.Equal(t, len("sibling"), e.AggregateBytes(), "the overflowed stream released its budget") | ||
| @@ -527,36 +537,16 @@ func TestStreamEngineQueueOverflow(t *testing.T) { | |||||
| 527 | require.ErrorIs(t, mustStatus(t, e, 1).Err, ErrStreamQueueFull) | 537 | require.ErrorIs(t, mustStatus(t, e, 1).Err, ErrStreamQueueFull) | ||
| 528 | } | 538 | } | ||
| 529 | 539 | ||||
| 530 | - | // TestStreamEngineQueueByteBounds proves the per-stream and aggregate byte | 540 | + | // TestStreamEngineQueueByteBounds proves the aggregate byte ceiling resets |
| 531 | - | // ceilings reset only the offending stream. | 541 | + | // only the offending stream. |
| 532 | func TestStreamEngineQueueByteBounds(t *testing.T) { | 542 | func TestStreamEngineQueueByteBounds(t *testing.T) { | ||
| 533 | - | t.Run("per-stream byte bound", func(t *testing.T) { | |||
| 534 | - | e := testEngine() | |||
| 535 | - | // The codec never admits a chunk above the negotiated chunk ceiling; | |||
| 536 | - | // raise the engine's private ceiling so this test can reach the | |||
| 537 | - | // defensive per-stream byte bound directly. | |||
| 538 | - | e.maxChunkBytes = MaxMuxStreamQueueBytes | |||
| 539 | - | openTestStream(t, e, 1) | |||
| 540 | - | chunk := make([]byte, MaxMuxStreamQueueBytes/2+1) | |||
| 541 | - | disposition, err := e.Data(Data{Physical: 1, Data: chunk}) | |||
| 542 | - | require.NoError(t, err) | |||
| 543 | - | require.Equal(t, StreamAccepted, disposition) | |||
| 544 | - | require.Equal(t, len(chunk), e.AggregateBytes()) | |||
| 545 | - | ||||
| 546 | - | // The second chunk fits the chunk-count and aggregate bounds but | |||
| 547 | - | // exceeds the per-stream byte bound. | |||
| 548 | - | disposition, err = e.Data(Data{Physical: 1, Data: chunk}) | |||
| 549 | - | require.ErrorIs(t, err, ErrStreamQueueFull) | |||
| 550 | - | require.Zero(t, disposition) | |||
| 551 | - | require.Zero(t, e.AggregateBytes(), "the reset stream released its bytes") | |||
| 552 | - | require.Equal(t, StreamTerminal, mustStatus(t, e, 1).State) | |||
| 553 | - | }) | |||
| 554 | - | ||||
| 555 | t.Run("aggregate byte bound", func(t *testing.T) { | 543 | t.Run("aggregate byte bound", func(t *testing.T) { | ||
| 556 | e := testEngine() | 544 | e := testEngine() | ||
| 557 | - | // As above: bypass the codec-level chunk ceiling to reach the | 545 | + | // Bypass the codec-level chunk ceiling and widen the window so this |
| 558 | - | // defensive aggregate byte bound with oversized chunks. | 546 | + | // test reaches the defensive aggregate byte bound with oversized |
| 547 | + | // chunks. | |||
| 559 | e.maxChunkBytes = 16 << 20 | 548 | e.maxChunkBytes = 16 << 20 | ||
| 549 | + | e.window = 32 << 20 | |||
| 560 | chunk := make([]byte, 16<<20) | 550 | chunk := make([]byte, 16<<20) | ||
| 561 | for i := 1; i <= 4; i++ { | 551 | for i := 1; i <= 4; i++ { | ||
| 562 | openTestStream(t, e, PhysicalStreamID(i)) | 552 | openTestStream(t, e, PhysicalStreamID(i)) | ||
M internal/adapters/daemonmux/limits.go +44 -1
| @@ -81,8 +81,43 @@ const ( | |||
| 81 | 81 | // MaxMuxAggregateBytes is the largest legal aggregate buffered-bytes | |
| 82 | 82 | // ceiling: 64 MiB across every live stream of one physical connection. | |
| 83 | 83 | MaxMuxAggregateBytes uint64 = 64 << 20 | |
| 84 | + | ||
| 85 | + | // MuxChunkCreditOverhead is the fixed credit one Data frame costs on top of | |
| 86 | + | // its payload bytes. Charging it keeps a burst of tiny frames bounded in | |
| 87 | + | // count as well as in bytes. | |
| 88 | + | MuxChunkCreditOverhead uint64 = MuxEnvelopeOverheadBytes | |
| 89 | + | // MaxMuxStreamWindowBytes caps one stream's receive window and every | |
| 90 | + | // WindowUpdate grant. | |
| 91 | + | MaxMuxStreamWindowBytes uint64 = 4 << 20 | |
| 84 | 92 | ) | |
| 85 | 93 | ||
| 94 | + | // chunkCredit is the flow-control cost of one Data frame of n payload bytes. | |
| 95 | + | func chunkCredit(n int) uint64 { return uint64(n) + MuxChunkCreditOverhead } | |
| 96 | + | ||
| 97 | + | // StreamWindow is the per-stream receive window both peers derive from the | |
| 98 | + | // same negotiated ceilings: the aggregate budget shared evenly across the | |
| 99 | + | // stream ceiling, never below one maximum chunk (a full chunk must always be | |
| 100 | + | // sendable) and never above MaxMuxStreamWindowBytes. With the default | |
| 101 | + | // ceilings it is 512 KiB. EffectiveMuxCeilings lowers the stream ceiling so | |
| 102 | + | // the one-chunk floor never pushes the sum of every live stream's window past | |
| 103 | + | // the aggregate: a conforming peer then never exceeds the aggregate budget. | |
| 104 | + | func (c MuxCeilings) StreamWindow() uint64 { | |
| 105 | + | window := c.MaxAggregateBytes / max(c.MaxStreams, 1) | |
| 106 | + | window = max(window, chunkCredit(int(c.StreamChunkLimit))) | |
| 107 | + | return min(window, MaxMuxStreamWindowBytes) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // creditReturnThreshold is the consumed credit a receiver batches before it | |
| 111 | + | // grants a WindowUpdate: half the window, lowered when needed so a sender | |
| 112 | + | // blocked on a maximum chunk is always unblocked. A sender waits only once it | |
| 113 | + | // spent more than window-chunkCredit(limit), so granting at or below that | |
| 114 | + | // point can never deadlock. | |
| 115 | + | func (c MuxCeilings) creditReturnThreshold() uint64 { | |
| 116 | + | window := c.StreamWindow() | |
| 117 | + | cost := chunkCredit(int(c.StreamChunkLimit)) | |
| 118 | + | return max(min(window/2, window-cost+1), 1) | |
| 119 | + | } | |
| 120 | + | ||
| 86 | 121 | // ErrInvalidCeilings reports an advertisement outside its window: a zero, | |
| 87 | 122 | // below-floor, or above-ceiling envelope, chunk, stream-count, or aggregate | |
| 88 | 123 | // bound. The preamble maps it to its own ErrPreambleRejected sentinel; the | |
| @@ -159,13 +194,21 @@ func DefaultMuxCeilings() MuxCeilings { | |||
| 159 | 194 | // the local policy. Each field is independent, so a peer that offers less in | |
| 160 | 195 | // one dimension lowers only that dimension. Both inputs are assumed | |
| 161 | 196 | // individually valid; the minima of two valid advertisements is valid. | |
| 197 | + | // | |
| 198 | + | // The stream ceiling is then lowered, when needed, so every stream can hold a | |
| 199 | + | // full window of one maximum chunk inside the aggregate budget at once. Both | |
| 200 | + | // peers apply the same rule, so they agree on the result; the aggregate floor | |
| 201 | + | // admits at least one stream. | |
| 162 | 202 | func EffectiveMuxCeilings(local, remote MuxCeilings) MuxCeilings { | |
| 163 | - | return MuxCeilings{ | |
| 203 | + | effective := MuxCeilings{ | |
| 164 | 204 | MaxReceiveEnvelopeBytes: min(local.MaxReceiveEnvelopeBytes, remote.MaxReceiveEnvelopeBytes), | |
| 165 | 205 | StreamChunkLimit: min(local.StreamChunkLimit, remote.StreamChunkLimit), | |
| 166 | 206 | MaxStreams: min(local.MaxStreams, remote.MaxStreams), | |
| 167 | 207 | MaxAggregateBytes: min(local.MaxAggregateBytes, remote.MaxAggregateBytes), | |
| 168 | 208 | } | |
| 209 | + | fair := effective.MaxAggregateBytes / chunkCredit(int(effective.StreamChunkLimit)) | |
| 210 | + | effective.MaxStreams = max(min(effective.MaxStreams, fair), MinMuxStreams) | |
| 211 | + | return effective | |
| 169 | 212 | } | |
| 170 | 213 | ||
| 171 | 214 | // checkMuxCeilings refuses an invalid advertisement as a preamble refusal so | |
| @@ -81,8 +81,43 @@ const ( | |||||
| 81 | // MaxMuxAggregateBytes is the largest legal aggregate buffered-bytes | 81 | // MaxMuxAggregateBytes is the largest legal aggregate buffered-bytes | ||
| 82 | // ceiling: 64 MiB across every live stream of one physical connection. | 82 | // ceiling: 64 MiB across every live stream of one physical connection. | ||
| 83 | MaxMuxAggregateBytes uint64 = 64 << 20 | 83 | MaxMuxAggregateBytes uint64 = 64 << 20 | ||
| 84 | + | ||||
| 85 | + | // MuxChunkCreditOverhead is the fixed credit one Data frame costs on top of | |||
| 86 | + | // its payload bytes. Charging it keeps a burst of tiny frames bounded in | |||
| 87 | + | // count as well as in bytes. | |||
| 88 | + | MuxChunkCreditOverhead uint64 = MuxEnvelopeOverheadBytes | |||
| 89 | + | // MaxMuxStreamWindowBytes caps one stream's receive window and every | |||
| 90 | + | // WindowUpdate grant. | |||
| 91 | + | MaxMuxStreamWindowBytes uint64 = 4 << 20 | |||
| 84 | ) | 92 | ) | ||
| 85 | 93 | ||||
| 94 | + | // chunkCredit is the flow-control cost of one Data frame of n payload bytes. | |||
| 95 | + | func chunkCredit(n int) uint64 { return uint64(n) + MuxChunkCreditOverhead } | |||
| 96 | + | ||||
| 97 | + | // StreamWindow is the per-stream receive window both peers derive from the | |||
| 98 | + | // same negotiated ceilings: the aggregate budget shared evenly across the | |||
| 99 | + | // stream ceiling, never below one maximum chunk (a full chunk must always be | |||
| 100 | + | // sendable) and never above MaxMuxStreamWindowBytes. With the default | |||
| 101 | + | // ceilings it is 512 KiB. EffectiveMuxCeilings lowers the stream ceiling so | |||
| 102 | + | // the one-chunk floor never pushes the sum of every live stream's window past | |||
| 103 | + | // the aggregate: a conforming peer then never exceeds the aggregate budget. | |||
| 104 | + | func (c MuxCeilings) StreamWindow() uint64 { | |||
| 105 | + | window := c.MaxAggregateBytes / max(c.MaxStreams, 1) | |||
| 106 | + | window = max(window, chunkCredit(int(c.StreamChunkLimit))) | |||
| 107 | + | return min(window, MaxMuxStreamWindowBytes) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // creditReturnThreshold is the consumed credit a receiver batches before it | |||
| 111 | + | // grants a WindowUpdate: half the window, lowered when needed so a sender | |||
| 112 | + | // blocked on a maximum chunk is always unblocked. A sender waits only once it | |||
| 113 | + | // spent more than window-chunkCredit(limit), so granting at or below that | |||
| 114 | + | // point can never deadlock. | |||
| 115 | + | func (c MuxCeilings) creditReturnThreshold() uint64 { | |||
| 116 | + | window := c.StreamWindow() | |||
| 117 | + | cost := chunkCredit(int(c.StreamChunkLimit)) | |||
| 118 | + | return max(min(window/2, window-cost+1), 1) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 86 | // ErrInvalidCeilings reports an advertisement outside its window: a zero, | 121 | // ErrInvalidCeilings reports an advertisement outside its window: a zero, | ||
| 87 | // below-floor, or above-ceiling envelope, chunk, stream-count, or aggregate | 122 | // below-floor, or above-ceiling envelope, chunk, stream-count, or aggregate | ||
| 88 | // bound. The preamble maps it to its own ErrPreambleRejected sentinel; the | 123 | // bound. The preamble maps it to its own ErrPreambleRejected sentinel; the | ||
| @@ -159,13 +194,21 @@ func DefaultMuxCeilings() MuxCeilings { | |||||
| 159 | // the local policy. Each field is independent, so a peer that offers less in | 194 | // the local policy. Each field is independent, so a peer that offers less in | ||
| 160 | // one dimension lowers only that dimension. Both inputs are assumed | 195 | // one dimension lowers only that dimension. Both inputs are assumed | ||
| 161 | // individually valid; the minima of two valid advertisements is valid. | 196 | // individually valid; the minima of two valid advertisements is valid. | ||
| 197 | + | // | |||
| 198 | + | // The stream ceiling is then lowered, when needed, so every stream can hold a | |||
| 199 | + | // full window of one maximum chunk inside the aggregate budget at once. Both | |||
| 200 | + | // peers apply the same rule, so they agree on the result; the aggregate floor | |||
| 201 | + | // admits at least one stream. | |||
| 162 | func EffectiveMuxCeilings(local, remote MuxCeilings) MuxCeilings { | 202 | func EffectiveMuxCeilings(local, remote MuxCeilings) MuxCeilings { | ||
| 163 | - | return MuxCeilings{ | 203 | + | effective := MuxCeilings{ |
| 164 | MaxReceiveEnvelopeBytes: min(local.MaxReceiveEnvelopeBytes, remote.MaxReceiveEnvelopeBytes), | 204 | MaxReceiveEnvelopeBytes: min(local.MaxReceiveEnvelopeBytes, remote.MaxReceiveEnvelopeBytes), | ||
| 165 | StreamChunkLimit: min(local.StreamChunkLimit, remote.StreamChunkLimit), | 205 | StreamChunkLimit: min(local.StreamChunkLimit, remote.StreamChunkLimit), | ||
| 166 | MaxStreams: min(local.MaxStreams, remote.MaxStreams), | 206 | MaxStreams: min(local.MaxStreams, remote.MaxStreams), | ||
| 167 | MaxAggregateBytes: min(local.MaxAggregateBytes, remote.MaxAggregateBytes), | 207 | MaxAggregateBytes: min(local.MaxAggregateBytes, remote.MaxAggregateBytes), | ||
| 168 | } | 208 | } | ||
| 209 | + | fair := effective.MaxAggregateBytes / chunkCredit(int(effective.StreamChunkLimit)) | |||
| 210 | + | effective.MaxStreams = max(min(effective.MaxStreams, fair), MinMuxStreams) | |||
| 211 | + | return effective | |||
| 169 | } | 212 | } | ||
| 170 | 213 | ||||
| 171 | // checkMuxCeilings refuses an invalid advertisement as a preamble refusal so | 214 | // checkMuxCeilings refuses an invalid advertisement as a preamble refusal so | ||
M internal/adapters/daemonmux/listener_test.go +3 -3
| @@ -747,7 +747,7 @@ func TestListenerMalformedStreamDoesNotStopAccept(t *testing.T) { | |||
| 747 | 747 | daemon.await(t, 1) | |
| 748 | 748 | ||
| 749 | 749 | // The peer speaks garbage on the first frame of its stream. | |
| 750 | - | require.NoError(t, h.brokerPump.Send(Data{Physical: 1, Data: []byte("not-a-session-frame")})) | |
| 750 | + | require.NoError(t, h.brokerPump.SendData(1, []byte("not-a-session-frame"), nil)) | |
| 751 | 751 | ||
| 752 | 752 | malformed := requireStreamState(t, h.daemonPump, 1, StreamTerminal) | |
| 753 | 753 | require.Equal(t, domain.RemoteFailureInvalidResponse, malformed.FailureKind) | |
| @@ -928,7 +928,7 @@ func TestListenerConnectionPreambleFailureLeavesNoResidue(t *testing.T) { | |||
| 928 | 928 | ||
| 929 | 929 | // The peer's first frame is not a valid session preamble; the failed | |
| 930 | 930 | // handshake closes the stream's carriage. | |
| 931 | - | require.NoError(t, h.brokerPump.Send(Data{Physical: 1, Data: []byte("not-a-session-preamble")})) | |
| 931 | + | require.NoError(t, h.brokerPump.SendData(1, []byte("not-a-session-preamble"), nil)) | |
| 932 | 932 | _, err := daemonConnection.ReceiveClient() | |
| 933 | 933 | require.Error(t, err) | |
| 934 | 934 | ||
| @@ -1042,7 +1042,7 @@ func TestListenerConnectionCloseDiscardsQueuedInboundReleasesAccounting(t *testi | |||
| 1042 | 1042 | ||
| 1043 | 1043 | // The broker sent one chunk the daemon never reads, so the stream holds | |
| 1044 | 1044 | // accepted inbound data when the local Close arrives. | |
| 1045 | - | require.NoError(t, h.brokerPump.Send(Data{Physical: id, Data: chunk})) | |
| 1045 | + | require.NoError(t, h.brokerPump.SendData(id, chunk, nil)) | |
| 1046 | 1046 | requireEngineEventually(t, h.daemonPump, func(e *StreamEngine) bool { return e.AggregateBytes() > 0 }, | |
| 1047 | 1047 | "cycle %d: the accepted chunk was never queued", i) | |
| 1048 | 1048 | ||
| @@ -747,7 +747,7 @@ func TestListenerMalformedStreamDoesNotStopAccept(t *testing.T) { | |||||
| 747 | daemon.await(t, 1) | 747 | daemon.await(t, 1) | ||
| 748 | 748 | ||||
| 749 | // The peer speaks garbage on the first frame of its stream. | 749 | // The peer speaks garbage on the first frame of its stream. | ||
| 750 | - | require.NoError(t, h.brokerPump.Send(Data{Physical: 1, Data: []byte("not-a-session-frame")})) | 750 | + | require.NoError(t, h.brokerPump.SendData(1, []byte("not-a-session-frame"), nil)) |
| 751 | 751 | ||||
| 752 | malformed := requireStreamState(t, h.daemonPump, 1, StreamTerminal) | 752 | malformed := requireStreamState(t, h.daemonPump, 1, StreamTerminal) | ||
| 753 | require.Equal(t, domain.RemoteFailureInvalidResponse, malformed.FailureKind) | 753 | require.Equal(t, domain.RemoteFailureInvalidResponse, malformed.FailureKind) | ||
| @@ -928,7 +928,7 @@ func TestListenerConnectionPreambleFailureLeavesNoResidue(t *testing.T) { | |||||
| 928 | 928 | ||||
| 929 | // The peer's first frame is not a valid session preamble; the failed | 929 | // The peer's first frame is not a valid session preamble; the failed | ||
| 930 | // handshake closes the stream's carriage. | 930 | // handshake closes the stream's carriage. | ||
| 931 | - | require.NoError(t, h.brokerPump.Send(Data{Physical: 1, Data: []byte("not-a-session-preamble")})) | 931 | + | require.NoError(t, h.brokerPump.SendData(1, []byte("not-a-session-preamble"), nil)) |
| 932 | _, err := daemonConnection.ReceiveClient() | 932 | _, err := daemonConnection.ReceiveClient() | ||
| 933 | require.Error(t, err) | 933 | require.Error(t, err) | ||
| 934 | 934 | ||||
| @@ -1042,7 +1042,7 @@ func TestListenerConnectionCloseDiscardsQueuedInboundReleasesAccounting(t *testi | |||||
| 1042 | 1042 | ||||
| 1043 | // The broker sent one chunk the daemon never reads, so the stream holds | 1043 | // The broker sent one chunk the daemon never reads, so the stream holds | ||
| 1044 | // accepted inbound data when the local Close arrives. | 1044 | // accepted inbound data when the local Close arrives. | ||
| 1045 | - | require.NoError(t, h.brokerPump.Send(Data{Physical: id, Data: chunk})) | 1045 | + | require.NoError(t, h.brokerPump.SendData(id, chunk, nil)) |
| 1046 | requireEngineEventually(t, h.daemonPump, func(e *StreamEngine) bool { return e.AggregateBytes() > 0 }, | 1046 | requireEngineEventually(t, h.daemonPump, func(e *StreamEngine) bool { return e.AggregateBytes() > 0 }, | ||
| 1047 | "cycle %d: the accepted chunk was never queued", i) | 1047 | "cycle %d: the accepted chunk was never queued", i) | ||
| 1048 | 1048 | ||||
M internal/adapters/daemonmux/logical_conn.go +11 -7
| @@ -551,9 +551,11 @@ func (s *muxStreamPipe) Read(p []byte) (int, error) { | |||
| 551 | 551 | s.mu.Unlock() | |
| 552 | 552 | continue | |
| 553 | 553 | } | |
| 554 | - | if watchClosed(watch) { | |
| 555 | - | // Watch returns an already-closed channel only for a terminal or | |
| 556 | - | // unknown stream, so no further inbound chunk can ever be queued. | |
| 554 | + | if watchSettled(watch) { | |
| 555 | + | // The stream is terminal or unknown, so no further inbound chunk | |
| 556 | + | // can ever be queued. A live stream's watch may also be closed | |
| 557 | + | // already, by a frame that arrived after Take: the select below | |
| 558 | + | // then returns at once and the loop takes it. | |
| 557 | 559 | return 0, io.EOF | |
| 558 | 560 | } | |
| 559 | 561 | select { | |
| @@ -569,8 +571,10 @@ func (s *muxStreamPipe) Read(p []byte) (int, error) { | |||
| 569 | 571 | } | |
| 570 | 572 | ||
| 571 | 573 | // Write splits p into ordered chunks at or below the negotiated chunk ceiling | |
| 572 | - | // and sends each as one Data frame. It reports the bytes fully sent before an | |
| 573 | - | // error. | |
| 574 | + | // and sends each as one Data frame under the stream's flow-control credit: it | |
| 575 | + | // blocks while the peer has not granted credit, so a slow link slows the | |
| 576 | + | // writer instead of resetting the stream. It reports the bytes fully sent | |
| 577 | + | // before an error. | |
| 574 | 578 | func (s *muxStreamPipe) Write(p []byte) (int, error) { | |
| 575 | 579 | written := 0 | |
| 576 | 580 | for len(p) > 0 { | |
| @@ -581,8 +585,8 @@ func (s *muxStreamPipe) Write(p []byte) (int, error) { | |||
| 581 | 585 | if uint64(n) > s.limit { | |
| 582 | 586 | n = int(s.limit) | |
| 583 | 587 | } | |
| 584 | - | if err := s.pump.Send(Data{Physical: s.id, Data: p[:n]}); err != nil { | |
| 585 | - | if s.closed() { | |
| 588 | + | if err := s.pump.SendData(s.id, p[:n], s.done); err != nil { | |
| 589 | + | if s.closed() || errors.Is(err, errStreamSettled) { | |
| 586 | 590 | return written, ErrLogicalClosed | |
| 587 | 591 | } | |
| 588 | 592 | return written, err | |
| @@ -551,9 +551,11 @@ func (s *muxStreamPipe) Read(p []byte) (int, error) { | |||||
| 551 | s.mu.Unlock() | 551 | s.mu.Unlock() | ||
| 552 | continue | 552 | continue | ||
| 553 | } | 553 | } | ||
| 554 | - | if watchClosed(watch) { | 554 | + | if watchSettled(watch) { |
| 555 | - | // Watch returns an already-closed channel only for a terminal or | 555 | + | // The stream is terminal or unknown, so no further inbound chunk |
| 556 | - | // unknown stream, so no further inbound chunk can ever be queued. | 556 | + | // can ever be queued. A live stream's watch may also be closed |
| 557 | + | // already, by a frame that arrived after Take: the select below | |||
| 558 | + | // then returns at once and the loop takes it. | |||
| 557 | return 0, io.EOF | 559 | return 0, io.EOF | ||
| 558 | } | 560 | } | ||
| 559 | select { | 561 | select { | ||
| @@ -569,8 +571,10 @@ func (s *muxStreamPipe) Read(p []byte) (int, error) { | |||||
| 569 | } | 571 | } | ||
| 570 | 572 | ||||
| 571 | // Write splits p into ordered chunks at or below the negotiated chunk ceiling | 573 | // Write splits p into ordered chunks at or below the negotiated chunk ceiling | ||
| 572 | - | // and sends each as one Data frame. It reports the bytes fully sent before an | 574 | + | // and sends each as one Data frame under the stream's flow-control credit: it |
| 573 | - | // error. | 575 | + | // blocks while the peer has not granted credit, so a slow link slows the |
| 576 | + | // writer instead of resetting the stream. It reports the bytes fully sent | |||
| 577 | + | // before an error. | |||
| 574 | func (s *muxStreamPipe) Write(p []byte) (int, error) { | 578 | func (s *muxStreamPipe) Write(p []byte) (int, error) { | ||
| 575 | written := 0 | 579 | written := 0 | ||
| 576 | for len(p) > 0 { | 580 | for len(p) > 0 { | ||
| @@ -581,8 +585,8 @@ func (s *muxStreamPipe) Write(p []byte) (int, error) { | |||||
| 581 | if uint64(n) > s.limit { | 585 | if uint64(n) > s.limit { | ||
| 582 | n = int(s.limit) | 586 | n = int(s.limit) | ||
| 583 | } | 587 | } | ||
| 584 | - | if err := s.pump.Send(Data{Physical: s.id, Data: p[:n]}); err != nil { | 588 | + | if err := s.pump.SendData(s.id, p[:n], s.done); err != nil { |
| 585 | - | if s.closed() { | 589 | + | if s.closed() || errors.Is(err, errStreamSettled) { |
| 586 | return written, ErrLogicalClosed | 590 | return written, ErrLogicalClosed | ||
| 587 | } | 591 | } | ||
| 588 | return written, err | 592 | return written, err | ||
M internal/adapters/daemonmux/logical_conn_test.go +28 -9
| @@ -557,15 +557,22 @@ func TestLogicalHundredConnectionsConcurrentAdmission(t *testing.T) { | |||
| 557 | 557 | require.False(t, channelClosed(daemonPump.Done())) | |
| 558 | 558 | } | |
| 559 | 559 | ||
| 560 | - | // TestLogicalBlockedReaderSiblingProgresses proves a logical connection whose | |
| 561 | - | // consumer never reads lets only its own bounded stream reset while a sibling | |
| 562 | - | // keeps exchanging typed messages and the physical connection stays open. | |
| 560 | + | // TestLogicalBlockedReaderSiblingProgresses is the regression for a slow | |
| 561 | + | // remote consumer killing its own attachment. The daemon floods far more | |
| 562 | + | // frames than one stream window holds while the consumer is not reading: the | |
| 563 | + | // daemon's writer must wait for credit instead of resetting the stream, the | |
| 564 | + | // sibling keeps exchanging typed messages, and once the consumer reads again | |
| 565 | + | // every flooded frame arrives in order on the still-open stream. | |
| 563 | 566 | func TestLogicalBlockedReaderSiblingProgresses(t *testing.T) { | |
| 564 | 567 | brokerPump, daemonPump := newPairedPumps(t, DefaultMuxCeilings()) | |
| 565 | 568 | connector := mustConnector(t, brokerPump) | |
| 566 | 569 | deadline := time.Now().Add(15 * time.Second) | |
| 567 | 570 | ||
| 568 | - | blockedAcceptor := newDaemonAcceptor(daemonPump, MaxMuxStreamQueueChunks+4) | |
| 571 | + | // Each Pong is one tiny frame, so the flood is far more frames than the | |
| 572 | + | // eight queued frames that used to reset the stream, and more credit than | |
| 573 | + | // one window. | |
| 574 | + | const flood = 20000 | |
| 575 | + | blockedAcceptor := newDaemonAcceptor(daemonPump, flood) | |
| 569 | 576 | blockedAcceptor.start(1, deadline) | |
| 570 | 577 | siblingAcceptor := newDaemonAcceptor(daemonPump, 1) | |
| 571 | 578 | siblingAcceptor.start(2, deadline) | |
| @@ -580,11 +587,14 @@ func TestLogicalBlockedReaderSiblingProgresses(t *testing.T) { | |||
| 580 | 587 | require.NoError(t, err) | |
| 581 | 588 | require.Equal(t, protocol.Pong{}, message) | |
| 582 | 589 | ||
| 583 | - | // Run the blocked stream's handshake, then never read it; the daemon floods | |
| 584 | - | // Pong frames until the blocked stream's own bounded queue overflows. | |
| 590 | + | // Trigger the flood, then do not read: the daemon fills the window and | |
| 591 | + | // its writer waits for credit. | |
| 585 | 592 | require.NoError(t, blocked.SendClient(protocol.Ping{})) | |
| 586 | - | require.Eventually(t, func() bool { return channelClosed(blocked.Done()) }, 5*time.Second, time.Millisecond) | |
| 587 | - | require.Error(t, blocked.Err()) | |
| 593 | + | require.Eventually(t, func() bool { | |
| 594 | + | status, ok := daemonPump.Engine().Status(1) | |
| 595 | + | return ok && status.SendCredit < chunkCredit(int(MaxMuxChunkBytes)) | |
| 596 | + | }, 5*time.Second, time.Millisecond, "the daemon never exhausted its credit") | |
| 597 | + | require.False(t, channelClosed(blocked.Done()), "a slow consumer must not reset its stream") | |
| 588 | 598 | ||
| 589 | 599 | // The sibling still progresses and the physical connection is untouched. | |
| 590 | 600 | require.NoError(t, sibling.SendClient(protocol.Ping{})) | |
| @@ -593,6 +603,15 @@ func TestLogicalBlockedReaderSiblingProgresses(t *testing.T) { | |||
| 593 | 603 | require.Equal(t, protocol.Pong{}, message) | |
| 594 | 604 | require.False(t, channelClosed(brokerPump.Done())) | |
| 595 | 605 | require.Equal(t, StreamOpen, mustStatus(t, brokerPump.Engine(), 2).State) | |
| 606 | + | ||
| 607 | + | // The consumer catches up: every flooded frame arrives on the same stream. | |
| 608 | + | for i := 0; i < flood; i++ { | |
| 609 | + | message, err := blocked.ReceiveServer() | |
| 610 | + | require.NoError(t, err, "frame %d", i) | |
| 611 | + | require.Equal(t, protocol.Pong{}, message) | |
| 612 | + | } | |
| 613 | + | require.False(t, channelClosed(blocked.Done())) | |
| 614 | + | require.Equal(t, StreamOpen, mustStatus(t, brokerPump.Engine(), 1).State) | |
| 596 | 615 | } | |
| 597 | 616 | ||
| 598 | 617 | // TestLogicalMalformedInnerFrameResetsOnlyStream proves a malformed inner | |
| @@ -620,7 +639,7 @@ func TestLogicalMalformedInnerFrameResetsOnlyStream(t *testing.T) { | |||
| 620 | 639 | ||
| 621 | 640 | // A zero-length inner frame is malformed: the client's first read fails and | |
| 622 | 641 | // must reset only this stream. | |
| 623 | - | require.NoError(t, daemonPump.Send(Data{Physical: 1, Data: []byte{0, 0, 0, 0}})) | |
| 642 | + | require.NoError(t, daemonPump.SendData(1, []byte{0, 0, 0, 0}, nil)) | |
| 624 | 643 | _, err = malformed.ReceiveServer() | |
| 625 | 644 | require.Error(t, err) | |
| 626 | 645 | require.True(t, channelClosed(malformed.Done())) | |
| @@ -557,15 +557,22 @@ func TestLogicalHundredConnectionsConcurrentAdmission(t *testing.T) { | |||||
| 557 | require.False(t, channelClosed(daemonPump.Done())) | 557 | require.False(t, channelClosed(daemonPump.Done())) | ||
| 558 | } | 558 | } | ||
| 559 | 559 | ||||
| 560 | - | // TestLogicalBlockedReaderSiblingProgresses proves a logical connection whose | 560 | + | // TestLogicalBlockedReaderSiblingProgresses is the regression for a slow |
| 561 | - | // consumer never reads lets only its own bounded stream reset while a sibling | 561 | + | // remote consumer killing its own attachment. The daemon floods far more |
| 562 | - | // keeps exchanging typed messages and the physical connection stays open. | 562 | + | // frames than one stream window holds while the consumer is not reading: the |
| 563 | + | // daemon's writer must wait for credit instead of resetting the stream, the | |||
| 564 | + | // sibling keeps exchanging typed messages, and once the consumer reads again | |||
| 565 | + | // every flooded frame arrives in order on the still-open stream. | |||
| 563 | func TestLogicalBlockedReaderSiblingProgresses(t *testing.T) { | 566 | func TestLogicalBlockedReaderSiblingProgresses(t *testing.T) { | ||
| 564 | brokerPump, daemonPump := newPairedPumps(t, DefaultMuxCeilings()) | 567 | brokerPump, daemonPump := newPairedPumps(t, DefaultMuxCeilings()) | ||
| 565 | connector := mustConnector(t, brokerPump) | 568 | connector := mustConnector(t, brokerPump) | ||
| 566 | deadline := time.Now().Add(15 * time.Second) | 569 | deadline := time.Now().Add(15 * time.Second) | ||
| 567 | 570 | ||||
| 568 | - | blockedAcceptor := newDaemonAcceptor(daemonPump, MaxMuxStreamQueueChunks+4) | 571 | + | // Each Pong is one tiny frame, so the flood is far more frames than the |
| 572 | + | // eight queued frames that used to reset the stream, and more credit than | |||
| 573 | + | // one window. | |||
| 574 | + | const flood = 20000 | |||
| 575 | + | blockedAcceptor := newDaemonAcceptor(daemonPump, flood) | |||
| 569 | blockedAcceptor.start(1, deadline) | 576 | blockedAcceptor.start(1, deadline) | ||
| 570 | siblingAcceptor := newDaemonAcceptor(daemonPump, 1) | 577 | siblingAcceptor := newDaemonAcceptor(daemonPump, 1) | ||
| 571 | siblingAcceptor.start(2, deadline) | 578 | siblingAcceptor.start(2, deadline) | ||
| @@ -580,11 +587,14 @@ func TestLogicalBlockedReaderSiblingProgresses(t *testing.T) { | |||||
| 580 | require.NoError(t, err) | 587 | require.NoError(t, err) | ||
| 581 | require.Equal(t, protocol.Pong{}, message) | 588 | require.Equal(t, protocol.Pong{}, message) | ||
| 582 | 589 | ||||
| 583 | - | // Run the blocked stream's handshake, then never read it; the daemon floods | 590 | + | // Trigger the flood, then do not read: the daemon fills the window and |
| 584 | - | // Pong frames until the blocked stream's own bounded queue overflows. | 591 | + | // its writer waits for credit. |
| 585 | require.NoError(t, blocked.SendClient(protocol.Ping{})) | 592 | require.NoError(t, blocked.SendClient(protocol.Ping{})) | ||
| 586 | - | require.Eventually(t, func() bool { return channelClosed(blocked.Done()) }, 5*time.Second, time.Millisecond) | 593 | + | require.Eventually(t, func() bool { |
| 587 | - | require.Error(t, blocked.Err()) | 594 | + | status, ok := daemonPump.Engine().Status(1) |
| 595 | + | return ok && status.SendCredit < chunkCredit(int(MaxMuxChunkBytes)) | |||
| 596 | + | }, 5*time.Second, time.Millisecond, "the daemon never exhausted its credit") | |||
| 597 | + | require.False(t, channelClosed(blocked.Done()), "a slow consumer must not reset its stream") | |||
| 588 | 598 | ||||
| 589 | // The sibling still progresses and the physical connection is untouched. | 599 | // The sibling still progresses and the physical connection is untouched. | ||
| 590 | require.NoError(t, sibling.SendClient(protocol.Ping{})) | 600 | require.NoError(t, sibling.SendClient(protocol.Ping{})) | ||
| @@ -593,6 +603,15 @@ func TestLogicalBlockedReaderSiblingProgresses(t *testing.T) { | |||||
| 593 | require.Equal(t, protocol.Pong{}, message) | 603 | require.Equal(t, protocol.Pong{}, message) | ||
| 594 | require.False(t, channelClosed(brokerPump.Done())) | 604 | require.False(t, channelClosed(brokerPump.Done())) | ||
| 595 | require.Equal(t, StreamOpen, mustStatus(t, brokerPump.Engine(), 2).State) | 605 | require.Equal(t, StreamOpen, mustStatus(t, brokerPump.Engine(), 2).State) | ||
| 606 | + | ||||
| 607 | + | // The consumer catches up: every flooded frame arrives on the same stream. | |||
| 608 | + | for i := 0; i < flood; i++ { | |||
| 609 | + | message, err := blocked.ReceiveServer() | |||
| 610 | + | require.NoError(t, err, "frame %d", i) | |||
| 611 | + | require.Equal(t, protocol.Pong{}, message) | |||
| 612 | + | } | |||
| 613 | + | require.False(t, channelClosed(blocked.Done())) | |||
| 614 | + | require.Equal(t, StreamOpen, mustStatus(t, brokerPump.Engine(), 1).State) | |||
| 596 | } | 615 | } | ||
| 597 | 616 | ||||
| 598 | // TestLogicalMalformedInnerFrameResetsOnlyStream proves a malformed inner | 617 | // TestLogicalMalformedInnerFrameResetsOnlyStream proves a malformed inner | ||
| @@ -620,7 +639,7 @@ func TestLogicalMalformedInnerFrameResetsOnlyStream(t *testing.T) { | |||||
| 620 | 639 | ||||
| 621 | // A zero-length inner frame is malformed: the client's first read fails and | 640 | // A zero-length inner frame is malformed: the client's first read fails and | ||
| 622 | // must reset only this stream. | 641 | // must reset only this stream. | ||
| 623 | - | require.NoError(t, daemonPump.Send(Data{Physical: 1, Data: []byte{0, 0, 0, 0}})) | 642 | + | require.NoError(t, daemonPump.SendData(1, []byte{0, 0, 0, 0}, nil)) |
| 624 | _, err = malformed.ReceiveServer() | 643 | _, err = malformed.ReceiveServer() | ||
| 625 | require.Error(t, err) | 644 | require.Error(t, err) | ||
| 626 | require.True(t, channelClosed(malformed.Done())) | 645 | require.True(t, channelClosed(malformed.Done())) | ||
M internal/adapters/daemonmux/messages.go +12 -0
| @@ -126,6 +126,18 @@ type Reset struct { | |||
| 126 | 126 | func (Reset) muxClientMessage() {} | |
| 127 | 127 | func (Reset) muxServerMessage() {} | |
| 128 | 128 | ||
| 129 | + | // WindowUpdate returns receive credit for one stream: the receiver consumed | |
| 130 | + | // Data frames worth Credit credit bytes (payload plus MuxChunkCreditOverhead | |
| 131 | + | // per frame, see chunkCredit), so the sender may spend that many more. It | |
| 132 | + | // travels in both directions over the same wire payload. | |
| 133 | + | type WindowUpdate struct { | |
| 134 | + | Physical PhysicalStreamID | |
| 135 | + | Credit uint64 | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (WindowUpdate) muxClientMessage() {} | |
| 139 | + | func (WindowUpdate) muxServerMessage() {} | |
| 140 | + | ||
| 129 | 141 | // Opened confirms one logical stream is established. | |
| 130 | 142 | type Opened struct { | |
| 131 | 143 | Ref StreamRef | |
| @@ -126,6 +126,18 @@ type Reset struct { | |||||
| 126 | func (Reset) muxClientMessage() {} | 126 | func (Reset) muxClientMessage() {} | ||
| 127 | func (Reset) muxServerMessage() {} | 127 | func (Reset) muxServerMessage() {} | ||
| 128 | 128 | ||||
| 129 | + | // WindowUpdate returns receive credit for one stream: the receiver consumed | |||
| 130 | + | // Data frames worth Credit credit bytes (payload plus MuxChunkCreditOverhead | |||
| 131 | + | // per frame, see chunkCredit), so the sender may spend that many more. It | |||
| 132 | + | // travels in both directions over the same wire payload. | |||
| 133 | + | type WindowUpdate struct { | |||
| 134 | + | Physical PhysicalStreamID | |||
| 135 | + | Credit uint64 | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func (WindowUpdate) muxClientMessage() {} | |||
| 139 | + | func (WindowUpdate) muxServerMessage() {} | |||
| 140 | + | ||||
| 129 | // Opened confirms one logical stream is established. | 141 | // Opened confirms one logical stream is established. | ||
| 130 | type Opened struct { | 142 | type Opened struct { | ||
| 131 | Ref StreamRef | 143 | Ref StreamRef | ||
M internal/adapters/daemonmux/preamble_test.go +35 -0
| @@ -300,6 +300,41 @@ func TestEffectiveMuxCeilings(t *testing.T) { | |||
| 300 | 300 | require.Equal(t, effective, EffectiveMuxCeilings(effective, effective)) | |
| 301 | 301 | } | |
| 302 | 302 | ||
| 303 | + | // TestEffectiveMuxCeilingsWindowsFitAggregate proves the negotiated stream | |
| 304 | + | // ceiling never lets the sum of every stream's full window exceed the | |
| 305 | + | // aggregate budget, so a peer that respects its credit can never trip the | |
| 306 | + | // aggregate bound and be reset for it. | |
| 307 | + | func TestEffectiveMuxCeilingsWindowsFitAggregate(t *testing.T) { | |
| 308 | + | cases := []struct { | |
| 309 | + | name string | |
| 310 | + | chunk uint64 | |
| 311 | + | streams uint64 | |
| 312 | + | aggregate uint64 | |
| 313 | + | wantStreams uint64 | |
| 314 | + | }{ | |
| 315 | + | {"defaults unchanged", MaxMuxChunkBytes, MaxMuxStreams, MaxMuxAggregateBytes, MaxMuxStreams}, | |
| 316 | + | {"aggregate floor admits one stream", MaxMuxChunkBytes, MaxMuxStreams, MinMuxAggregateBytes, 1}, | |
| 317 | + | {"1 MiB aggregate with max chunks", MaxMuxChunkBytes, MaxMuxStreams, 1 << 20, (1 << 20) / chunkCredit(int(MaxMuxChunkBytes))}, | |
| 318 | + | {"small chunks keep every stream", 1024, MaxMuxStreams, 1 << 20, MaxMuxStreams}, | |
| 319 | + | } | |
| 320 | + | for _, tc := range cases { | |
| 321 | + | t.Run(tc.name, func(t *testing.T) { | |
| 322 | + | offer := MuxCeilings{ | |
| 323 | + | MaxReceiveEnvelopeBytes: MinMuxEnvelopeBytes, | |
| 324 | + | StreamChunkLimit: tc.chunk, | |
| 325 | + | MaxStreams: tc.streams, | |
| 326 | + | MaxAggregateBytes: tc.aggregate, | |
| 327 | + | } | |
| 328 | + | effective := EffectiveMuxCeilings(DefaultMuxCeilings(), offer) | |
| 329 | + | require.NoError(t, effective.Validate()) | |
| 330 | + | require.Equal(t, tc.wantStreams, effective.MaxStreams) | |
| 331 | + | require.LessOrEqual(t, effective.MaxStreams*effective.StreamWindow(), effective.MaxAggregateBytes) | |
| 332 | + | require.Equal(t, effective, EffectiveMuxCeilings(effective, effective), "idempotent, so both peers agree") | |
| 333 | + | require.NoError(t, ValidatePreambleResponseAgainstOffer(MuxPreambleResponse{Accepted: true, Ceilings: effective}, offer)) | |
| 334 | + | }) | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 303 | 338 | // TestValidatePreambleResponseAgainstOffer proves an accepted response is | |
| 304 | 339 | // refused when any effective ceiling exceeds the local offer, and accepted | |
| 305 | 340 | // when every ceiling is at or below it. A refusal, or an invalid offer, is a | |
| @@ -300,6 +300,41 @@ func TestEffectiveMuxCeilings(t *testing.T) { | |||||
| 300 | require.Equal(t, effective, EffectiveMuxCeilings(effective, effective)) | 300 | require.Equal(t, effective, EffectiveMuxCeilings(effective, effective)) | ||
| 301 | } | 301 | } | ||
| 302 | 302 | ||||
| 303 | + | // TestEffectiveMuxCeilingsWindowsFitAggregate proves the negotiated stream | |||
| 304 | + | // ceiling never lets the sum of every stream's full window exceed the | |||
| 305 | + | // aggregate budget, so a peer that respects its credit can never trip the | |||
| 306 | + | // aggregate bound and be reset for it. | |||
| 307 | + | func TestEffectiveMuxCeilingsWindowsFitAggregate(t *testing.T) { | |||
| 308 | + | cases := []struct { | |||
| 309 | + | name string | |||
| 310 | + | chunk uint64 | |||
| 311 | + | streams uint64 | |||
| 312 | + | aggregate uint64 | |||
| 313 | + | wantStreams uint64 | |||
| 314 | + | }{ | |||
| 315 | + | {"defaults unchanged", MaxMuxChunkBytes, MaxMuxStreams, MaxMuxAggregateBytes, MaxMuxStreams}, | |||
| 316 | + | {"aggregate floor admits one stream", MaxMuxChunkBytes, MaxMuxStreams, MinMuxAggregateBytes, 1}, | |||
| 317 | + | {"1 MiB aggregate with max chunks", MaxMuxChunkBytes, MaxMuxStreams, 1 << 20, (1 << 20) / chunkCredit(int(MaxMuxChunkBytes))}, | |||
| 318 | + | {"small chunks keep every stream", 1024, MaxMuxStreams, 1 << 20, MaxMuxStreams}, | |||
| 319 | + | } | |||
| 320 | + | for _, tc := range cases { | |||
| 321 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 322 | + | offer := MuxCeilings{ | |||
| 323 | + | MaxReceiveEnvelopeBytes: MinMuxEnvelopeBytes, | |||
| 324 | + | StreamChunkLimit: tc.chunk, | |||
| 325 | + | MaxStreams: tc.streams, | |||
| 326 | + | MaxAggregateBytes: tc.aggregate, | |||
| 327 | + | } | |||
| 328 | + | effective := EffectiveMuxCeilings(DefaultMuxCeilings(), offer) | |||
| 329 | + | require.NoError(t, effective.Validate()) | |||
| 330 | + | require.Equal(t, tc.wantStreams, effective.MaxStreams) | |||
| 331 | + | require.LessOrEqual(t, effective.MaxStreams*effective.StreamWindow(), effective.MaxAggregateBytes) | |||
| 332 | + | require.Equal(t, effective, EffectiveMuxCeilings(effective, effective), "idempotent, so both peers agree") | |||
| 333 | + | require.NoError(t, ValidatePreambleResponseAgainstOffer(MuxPreambleResponse{Accepted: true, Ceilings: effective}, offer)) | |||
| 334 | + | }) | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | ||||
| 303 | // TestValidatePreambleResponseAgainstOffer proves an accepted response is | 338 | // TestValidatePreambleResponseAgainstOffer proves an accepted response is | ||
| 304 | // refused when any effective ceiling exceeds the local offer, and accepted | 339 | // refused when any effective ceiling exceeds the local offer, and accepted | ||
| 305 | // when every ceiling is at or below it. A refusal, or an invalid offer, is a | 340 | // when every ceiling is at or below it. A refusal, or an invalid offer, is a | ||
M internal/adapters/daemonmux/pump.go +121 -9
| @@ -64,6 +64,7 @@ package daemonmux | |||
| 64 | 64 | import ( | |
| 65 | 65 | "context" | |
| 66 | 66 | "errors" | |
| 67 | + | "log/slog" | |
| 67 | 68 | "sync" | |
| 68 | 69 | ||
| 69 | 70 | "github.com/bnema/vev/internal/domain" | |
| @@ -90,6 +91,10 @@ type FramedCarrier interface { | |||
| 90 | 91 | // unknown inbound direction. | |
| 91 | 92 | var ErrPumpConfig = errors.New("daemonmux: invalid pump configuration") | |
| 92 | 93 | ||
| 94 | + | // ErrDataNeedsCredit reports a Data frame handed to Send: stream data must go | |
| 95 | + | // through SendData so it is sent under the stream's flow-control credit. | |
| 96 | + | var ErrDataNeedsCredit = errors.New("daemonmux: stream data must be sent with SendData") | |
| 97 | + | ||
| 93 | 98 | // ErrAdmissionObserverLate reports an admission-observer registration attempted | |
| 94 | 99 | // after Start. The reader goroutine is already running and may have admitted an | |
| 95 | 100 | // inbound Open, so a late observer would silently miss it; a consumer must | |
| @@ -310,9 +315,63 @@ func (p *Pump) Take(physical PhysicalStreamID) ([]byte, bool) { | |||
| 310 | 315 | if finalized { | |
| 311 | 316 | p.signalWatch(physical) | |
| 312 | 317 | } | |
| 318 | + | if ok { | |
| 319 | + | p.returnCredit(physical) | |
| 320 | + | } | |
| 313 | 321 | return chunk, ok | |
| 314 | 322 | } | |
| 315 | 323 | ||
| 324 | + | // returnCredit grants consumed inbound credit back to the peer once a batch | |
| 325 | + | // is due. A refusal for a settled stream is dropped; an aggregate overflow | |
| 326 | + | // resets the stream like any outbound overflow, because the lost grant would | |
| 327 | + | // otherwise leave the peer's writer short of credit forever. | |
| 328 | + | func (p *Pump) returnCredit(physical PhysicalStreamID) { | |
| 329 | + | grant := p.engine.takeReturn(physical) | |
| 330 | + | if grant == 0 || p.isTerminal() { | |
| 331 | + | return | |
| 332 | + | } | |
| 333 | + | _ = p.enqueue(WindowUpdate{Physical: physical, Credit: grant}) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // SendData sends one stream data chunk under the stream's flow-control | |
| 337 | + | // credit. It blocks while the peer has not granted enough credit, so a slow | |
| 338 | + | // network or a slow remote consumer slows the writer instead of resetting the | |
| 339 | + | // stream. It returns when the chunk is queued, the stream or the connection | |
| 340 | + | // settles, or cancel closes. | |
| 341 | + | func (p *Pump) SendData(physical PhysicalStreamID, chunk []byte, cancel <-chan struct{}) error { | |
| 342 | + | if p == nil { | |
| 343 | + | return ErrPumpConfig | |
| 344 | + | } | |
| 345 | + | for { | |
| 346 | + | if p.isTerminal() { | |
| 347 | + | return ErrPhysicalClosed | |
| 348 | + | } | |
| 349 | + | ok, wake, err := p.engine.reserveSend(physical, len(chunk)) | |
| 350 | + | if err != nil { | |
| 351 | + | return err | |
| 352 | + | } | |
| 353 | + | if ok { | |
| 354 | + | break | |
| 355 | + | } | |
| 356 | + | select { | |
| 357 | + | case <-wake: | |
| 358 | + | case <-cancel: | |
| 359 | + | return ErrLogicalClosed | |
| 360 | + | case <-p.Done(): | |
| 361 | + | return ErrPhysicalClosed | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | if p.isTerminal() { | |
| 365 | + | p.engine.refundSend(physical, len(chunk)) | |
| 366 | + | return ErrPhysicalClosed | |
| 367 | + | } | |
| 368 | + | if err := p.enqueue(Data{Physical: physical, Data: chunk}); err != nil { | |
| 369 | + | p.engine.refundSend(physical, len(chunk)) | |
| 370 | + | return err | |
| 371 | + | } | |
| 372 | + | return nil | |
| 373 | + | } | |
| 374 | + | ||
| 316 | 375 | // Ceilings returns the negotiated ceilings the pump enforces. The value is | |
| 317 | 376 | // immutable for the pump's lifetime. | |
| 318 | 377 | func (p *Pump) Ceilings() MuxCeilings { | |
| @@ -471,9 +530,15 @@ var closedWatchChan = func() <-chan struct{} { | |||
| 471 | 530 | ||
| 472 | 531 | func closedChannel() <-chan struct{} { return closedWatchChan } | |
| 473 | 532 | ||
| 474 | - | // watchClosed reports whether a channel returned by Watch is already closed, | |
| 475 | - | // which means its stream is terminal or unknown and can never receive another | |
| 476 | - | // applied frame. | |
| 533 | + | // watchSettled reports whether a channel returned by Watch marks a terminal or | |
| 534 | + | // unknown stream. Only that case returns the shared closed channel: a live | |
| 535 | + | // stream's watch channel is also closed, by signalWatch, whenever a frame | |
| 536 | + | // arrives, so being closed alone never means the stream ended. | |
| 537 | + | func watchSettled(ch <-chan struct{}) bool { return ch == closedWatchChan } | |
| 538 | + | ||
| 539 | + | // watchClosed reports whether a signal channel is already closed. Use | |
| 540 | + | // watchSettled, not this, to tell whether a Watch channel marks a settled | |
| 541 | + | // stream. | |
| 477 | 542 | func watchClosed(ch <-chan struct{}) bool { | |
| 478 | 543 | select { | |
| 479 | 544 | case <-ch: | |
| @@ -490,8 +555,9 @@ func watchClosed(ch <-chan struct{}) bool { | |||
| 490 | 555 | // values satisfy both. A wrong-direction value fails with ErrWrongDirection | |
| 491 | 556 | // before anything is encoded or queued, a message the codec refuses propagates | |
| 492 | 557 | // the codec's error, and a pump that already reached its terminal outcome | |
| 493 | - | // fails with ErrPhysicalClosed. On success the encoded bytes are immutable and | |
| 494 | - | // owned by the writer. | |
| 558 | + | // fails with ErrPhysicalClosed. Data is refused with ErrDataNeedsCredit: | |
| 559 | + | // stream data goes through SendData. On success the encoded bytes are | |
| 560 | + | // immutable and owned by the writer. | |
| 495 | 561 | // | |
| 496 | 562 | // An accepted Send only means the frame is queued, never that it reached the | |
| 497 | 563 | // wire: the writer drains asynchronously. Call Flush to wait until every | |
| @@ -509,9 +575,19 @@ func (p *Pump) Send(message any) error { | |||
| 509 | 575 | if message == nil { | |
| 510 | 576 | return ErrInvalidMessage | |
| 511 | 577 | } | |
| 578 | + | switch message.(type) { | |
| 579 | + | case Data, *Data: | |
| 580 | + | return ErrDataNeedsCredit | |
| 581 | + | } | |
| 512 | 582 | if p.isTerminal() { | |
| 513 | 583 | return ErrPhysicalClosed | |
| 514 | 584 | } | |
| 585 | + | return p.enqueue(message) | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | // enqueue queues one validated outbound message on the scheduler in the | |
| 589 | + | // pump's local direction and isolates a stream-local overflow. | |
| 590 | + | func (p *Pump) enqueue(message any) error { | |
| 515 | 591 | switch p.local { | |
| 516 | 592 | case DirectionClient: | |
| 517 | 593 | client, ok := message.(ClientMessage) | |
| @@ -756,12 +832,20 @@ func (p *Pump) applyClient(message ClientMessage) error { | |||
| 756 | 832 | return p.applyClient(*m) | |
| 757 | 833 | case Reset: | |
| 758 | 834 | disposition, err := p.engine.Reset(m) | |
| 835 | + | p.logPeerReset(m, disposition) | |
| 759 | 836 | return p.applyStreamTerminal(m.Physical, disposition, err) | |
| 760 | 837 | case *Reset: | |
| 761 | 838 | if m == nil { | |
| 762 | 839 | return ErrInvalidMessage | |
| 763 | 840 | } | |
| 764 | 841 | return p.applyClient(*m) | |
| 842 | + | case WindowUpdate: | |
| 843 | + | return p.applyWindowUpdate(m) | |
| 844 | + | case *WindowUpdate: | |
| 845 | + | if m == nil { | |
| 846 | + | return ErrInvalidMessage | |
| 847 | + | } | |
| 848 | + | return p.applyClient(*m) | |
| 765 | 849 | default: | |
| 766 | 850 | return ErrWrongDirection | |
| 767 | 851 | } | |
| @@ -848,12 +932,20 @@ func (p *Pump) applyServer(message ServerMessage) error { | |||
| 848 | 932 | return p.applyServer(*m) | |
| 849 | 933 | case Reset: | |
| 850 | 934 | disposition, err := p.engine.Reset(m) | |
| 935 | + | p.logPeerReset(m, disposition) | |
| 851 | 936 | return p.applyStreamTerminal(m.Physical, disposition, err) | |
| 852 | 937 | case *Reset: | |
| 853 | 938 | if m == nil { | |
| 854 | 939 | return ErrInvalidMessage | |
| 855 | 940 | } | |
| 856 | 941 | return p.applyServer(*m) | |
| 942 | + | case WindowUpdate: | |
| 943 | + | return p.applyWindowUpdate(m) | |
| 944 | + | case *WindowUpdate: | |
| 945 | + | if m == nil { | |
| 946 | + | return ErrInvalidMessage | |
| 947 | + | } | |
| 948 | + | return p.applyServer(*m) | |
| 857 | 949 | default: | |
| 858 | 950 | return ErrWrongDirection | |
| 859 | 951 | } | |
| @@ -876,6 +968,25 @@ func (p *Pump) applyStreamTerminal(physical PhysicalStreamID, disposition Stream | |||
| 876 | 968 | return nil | |
| 877 | 969 | } | |
| 878 | 970 | ||
| 971 | + | // logPeerReset records one stream the peer aborted, with the reason it sent. | |
| 972 | + | // A late reset for an already settled stream is not logged. | |
| 973 | + | func (p *Pump) logPeerReset(message Reset, disposition StreamDisposition) { | |
| 974 | + | if disposition != StreamAccepted || !message.HasError { | |
| 975 | + | return | |
| 976 | + | } | |
| 977 | + | slog.Warn("daemonmux_stream_reset", "side", p.local.String(), "stream", uint64(message.Physical), "initiator", "peer", "kind", message.Error.FailureKind.String(), "code", message.Error.Code.String(), "cause", message.Error.Text) | |
| 978 | + | } | |
| 979 | + | ||
| 980 | + | // applyWindowUpdate grows one stream's send credit. A credit violation resets | |
| 981 | + | // only that stream. | |
| 982 | + | func (p *Pump) applyWindowUpdate(message WindowUpdate) error { | |
| 983 | + | _, err := p.engine.WindowUpdate(message) | |
| 984 | + | if err == nil { | |
| 985 | + | return nil | |
| 986 | + | } | |
| 987 | + | return p.applyStreamError(message.Physical, err) | |
| 988 | + | } | |
| 989 | + | ||
| 879 | 990 | // applyData queues one inbound chunk and classifies the engine's refusal. | |
| 880 | 991 | func (p *Pump) applyData(message Data) error { | |
| 881 | 992 | _, err := p.engine.Data(message) | |
| @@ -917,6 +1028,7 @@ func (p *Pump) scheduleReset(physical PhysicalStreamID, cause error) { | |||
| 917 | 1028 | } | |
| 918 | 1029 | detail := resetErrorDetail(cause) | |
| 919 | 1030 | reset := Reset{Physical: physical, Error: detail, HasError: true} | |
| 1031 | + | slog.Warn("daemonmux_stream_reset", "side", p.local.String(), "stream", uint64(physical), "initiator", "local", "kind", detail.FailureKind.String(), "cause", cause) | |
| 920 | 1032 | _, _ = p.engine.Reset(reset) | |
| 921 | 1033 | var err error | |
| 922 | 1034 | if p.local == DirectionServer { | |
| @@ -941,6 +1053,7 @@ func (p *Pump) settleFailure(kind domain.RemoteFailureKind, cause error) { | |||
| 941 | 1053 | p.termMu.Lock() | |
| 942 | 1054 | if !p.settled { | |
| 943 | 1055 | p.settled = true | |
| 1056 | + | slog.Warn("daemonmux_physical_failed", "side", p.local.String(), "kind", kind.String(), "live_streams", p.engine.Live(), "cause", cause) | |
| 944 | 1057 | p.terminal.Fail(kind, cause) | |
| 945 | 1058 | p.engine.Fail(kind, cause) | |
| 946 | 1059 | } | |
| @@ -1025,10 +1138,9 @@ func openRefusalDetail(cause error) ErrorDetail { | |||
| 1025 | 1138 | // response. | |
| 1026 | 1139 | func resetErrorDetail(cause error) ErrorDetail { | |
| 1027 | 1140 | kind := domain.RemoteFailureInvalidResponse | |
| 1028 | - | if errors.Is(cause, ErrStreamQueueFull) || errors.Is(cause, ErrSchedulerFull) { | |
| 1029 | - | // A local byte-budget overflow is a transport-class abort in either | |
| 1030 | - | // direction: the stream was reset because a bounded queue could not | |
| 1031 | - | // hold the frame, not because the peer sent something invalid. | |
| 1141 | + | if errors.Is(cause, ErrSchedulerFull) { | |
| 1142 | + | // The local aggregate safety net: a transport-class abort, not an | |
| 1143 | + | // invalid peer frame. | |
| 1032 | 1144 | kind = domain.RemoteFailureTransport | |
| 1033 | 1145 | } | |
| 1034 | 1146 | return ErrorDetail{ | |
| @@ -64,6 +64,7 @@ package daemonmux | |||||
| 64 | import ( | 64 | import ( | ||
| 65 | "context" | 65 | "context" | ||
| 66 | "errors" | 66 | "errors" | ||
| 67 | + | "log/slog" | |||
| 67 | "sync" | 68 | "sync" | ||
| 68 | 69 | ||||
| 69 | "github.com/bnema/vev/internal/domain" | 70 | "github.com/bnema/vev/internal/domain" | ||
| @@ -90,6 +91,10 @@ type FramedCarrier interface { | |||||
| 90 | // unknown inbound direction. | 91 | // unknown inbound direction. | ||
| 91 | var ErrPumpConfig = errors.New("daemonmux: invalid pump configuration") | 92 | var ErrPumpConfig = errors.New("daemonmux: invalid pump configuration") | ||
| 92 | 93 | ||||
| 94 | + | // ErrDataNeedsCredit reports a Data frame handed to Send: stream data must go | |||
| 95 | + | // through SendData so it is sent under the stream's flow-control credit. | |||
| 96 | + | var ErrDataNeedsCredit = errors.New("daemonmux: stream data must be sent with SendData") | |||
| 97 | + | ||||
| 93 | // ErrAdmissionObserverLate reports an admission-observer registration attempted | 98 | // ErrAdmissionObserverLate reports an admission-observer registration attempted | ||
| 94 | // after Start. The reader goroutine is already running and may have admitted an | 99 | // after Start. The reader goroutine is already running and may have admitted an | ||
| 95 | // inbound Open, so a late observer would silently miss it; a consumer must | 100 | // inbound Open, so a late observer would silently miss it; a consumer must | ||
| @@ -310,9 +315,63 @@ func (p *Pump) Take(physical PhysicalStreamID) ([]byte, bool) { | |||||
| 310 | if finalized { | 315 | if finalized { | ||
| 311 | p.signalWatch(physical) | 316 | p.signalWatch(physical) | ||
| 312 | } | 317 | } | ||
| 318 | + | if ok { | |||
| 319 | + | p.returnCredit(physical) | |||
| 320 | + | } | |||
| 313 | return chunk, ok | 321 | return chunk, ok | ||
| 314 | } | 322 | } | ||
| 315 | 323 | ||||
| 324 | + | // returnCredit grants consumed inbound credit back to the peer once a batch | |||
| 325 | + | // is due. A refusal for a settled stream is dropped; an aggregate overflow | |||
| 326 | + | // resets the stream like any outbound overflow, because the lost grant would | |||
| 327 | + | // otherwise leave the peer's writer short of credit forever. | |||
| 328 | + | func (p *Pump) returnCredit(physical PhysicalStreamID) { | |||
| 329 | + | grant := p.engine.takeReturn(physical) | |||
| 330 | + | if grant == 0 || p.isTerminal() { | |||
| 331 | + | return | |||
| 332 | + | } | |||
| 333 | + | _ = p.enqueue(WindowUpdate{Physical: physical, Credit: grant}) | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // SendData sends one stream data chunk under the stream's flow-control | |||
| 337 | + | // credit. It blocks while the peer has not granted enough credit, so a slow | |||
| 338 | + | // network or a slow remote consumer slows the writer instead of resetting the | |||
| 339 | + | // stream. It returns when the chunk is queued, the stream or the connection | |||
| 340 | + | // settles, or cancel closes. | |||
| 341 | + | func (p *Pump) SendData(physical PhysicalStreamID, chunk []byte, cancel <-chan struct{}) error { | |||
| 342 | + | if p == nil { | |||
| 343 | + | return ErrPumpConfig | |||
| 344 | + | } | |||
| 345 | + | for { | |||
| 346 | + | if p.isTerminal() { | |||
| 347 | + | return ErrPhysicalClosed | |||
| 348 | + | } | |||
| 349 | + | ok, wake, err := p.engine.reserveSend(physical, len(chunk)) | |||
| 350 | + | if err != nil { | |||
| 351 | + | return err | |||
| 352 | + | } | |||
| 353 | + | if ok { | |||
| 354 | + | break | |||
| 355 | + | } | |||
| 356 | + | select { | |||
| 357 | + | case <-wake: | |||
| 358 | + | case <-cancel: | |||
| 359 | + | return ErrLogicalClosed | |||
| 360 | + | case <-p.Done(): | |||
| 361 | + | return ErrPhysicalClosed | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | if p.isTerminal() { | |||
| 365 | + | p.engine.refundSend(physical, len(chunk)) | |||
| 366 | + | return ErrPhysicalClosed | |||
| 367 | + | } | |||
| 368 | + | if err := p.enqueue(Data{Physical: physical, Data: chunk}); err != nil { | |||
| 369 | + | p.engine.refundSend(physical, len(chunk)) | |||
| 370 | + | return err | |||
| 371 | + | } | |||
| 372 | + | return nil | |||
| 373 | + | } | |||
| 374 | + | ||||
| 316 | // Ceilings returns the negotiated ceilings the pump enforces. The value is | 375 | // Ceilings returns the negotiated ceilings the pump enforces. The value is | ||
| 317 | // immutable for the pump's lifetime. | 376 | // immutable for the pump's lifetime. | ||
| 318 | func (p *Pump) Ceilings() MuxCeilings { | 377 | func (p *Pump) Ceilings() MuxCeilings { | ||
| @@ -471,9 +530,15 @@ var closedWatchChan = func() <-chan struct{} { | |||||
| 471 | 530 | ||||
| 472 | func closedChannel() <-chan struct{} { return closedWatchChan } | 531 | func closedChannel() <-chan struct{} { return closedWatchChan } | ||
| 473 | 532 | ||||
| 474 | - | // watchClosed reports whether a channel returned by Watch is already closed, | 533 | + | // watchSettled reports whether a channel returned by Watch marks a terminal or |
| 475 | - | // which means its stream is terminal or unknown and can never receive another | 534 | + | // unknown stream. Only that case returns the shared closed channel: a live |
| 476 | - | // applied frame. | 535 | + | // stream's watch channel is also closed, by signalWatch, whenever a frame |
| 536 | + | // arrives, so being closed alone never means the stream ended. | |||
| 537 | + | func watchSettled(ch <-chan struct{}) bool { return ch == closedWatchChan } | |||
| 538 | + | ||||
| 539 | + | // watchClosed reports whether a signal channel is already closed. Use | |||
| 540 | + | // watchSettled, not this, to tell whether a Watch channel marks a settled | |||
| 541 | + | // stream. | |||
| 477 | func watchClosed(ch <-chan struct{}) bool { | 542 | func watchClosed(ch <-chan struct{}) bool { | ||
| 478 | select { | 543 | select { | ||
| 479 | case <-ch: | 544 | case <-ch: | ||
| @@ -490,8 +555,9 @@ func watchClosed(ch <-chan struct{}) bool { | |||||
| 490 | // values satisfy both. A wrong-direction value fails with ErrWrongDirection | 555 | // values satisfy both. A wrong-direction value fails with ErrWrongDirection | ||
| 491 | // before anything is encoded or queued, a message the codec refuses propagates | 556 | // before anything is encoded or queued, a message the codec refuses propagates | ||
| 492 | // the codec's error, and a pump that already reached its terminal outcome | 557 | // the codec's error, and a pump that already reached its terminal outcome | ||
| 493 | - | // fails with ErrPhysicalClosed. On success the encoded bytes are immutable and | 558 | + | // fails with ErrPhysicalClosed. Data is refused with ErrDataNeedsCredit: |
| 494 | - | // owned by the writer. | 559 | + | // stream data goes through SendData. On success the encoded bytes are |
| 560 | + | // immutable and owned by the writer. | |||
| 495 | // | 561 | // | ||
| 496 | // An accepted Send only means the frame is queued, never that it reached the | 562 | // An accepted Send only means the frame is queued, never that it reached the | ||
| 497 | // wire: the writer drains asynchronously. Call Flush to wait until every | 563 | // wire: the writer drains asynchronously. Call Flush to wait until every | ||
| @@ -509,9 +575,19 @@ func (p *Pump) Send(message any) error { | |||||
| 509 | if message == nil { | 575 | if message == nil { | ||
| 510 | return ErrInvalidMessage | 576 | return ErrInvalidMessage | ||
| 511 | } | 577 | } | ||
| 578 | + | switch message.(type) { | |||
| 579 | + | case Data, *Data: | |||
| 580 | + | return ErrDataNeedsCredit | |||
| 581 | + | } | |||
| 512 | if p.isTerminal() { | 582 | if p.isTerminal() { | ||
| 513 | return ErrPhysicalClosed | 583 | return ErrPhysicalClosed | ||
| 514 | } | 584 | } | ||
| 585 | + | return p.enqueue(message) | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | // enqueue queues one validated outbound message on the scheduler in the | |||
| 589 | + | // pump's local direction and isolates a stream-local overflow. | |||
| 590 | + | func (p *Pump) enqueue(message any) error { | |||
| 515 | switch p.local { | 591 | switch p.local { | ||
| 516 | case DirectionClient: | 592 | case DirectionClient: | ||
| 517 | client, ok := message.(ClientMessage) | 593 | client, ok := message.(ClientMessage) | ||
| @@ -756,12 +832,20 @@ func (p *Pump) applyClient(message ClientMessage) error { | |||||
| 756 | return p.applyClient(*m) | 832 | return p.applyClient(*m) | ||
| 757 | case Reset: | 833 | case Reset: | ||
| 758 | disposition, err := p.engine.Reset(m) | 834 | disposition, err := p.engine.Reset(m) | ||
| 835 | + | p.logPeerReset(m, disposition) | |||
| 759 | return p.applyStreamTerminal(m.Physical, disposition, err) | 836 | return p.applyStreamTerminal(m.Physical, disposition, err) | ||
| 760 | case *Reset: | 837 | case *Reset: | ||
| 761 | if m == nil { | 838 | if m == nil { | ||
| 762 | return ErrInvalidMessage | 839 | return ErrInvalidMessage | ||
| 763 | } | 840 | } | ||
| 764 | return p.applyClient(*m) | 841 | return p.applyClient(*m) | ||
| 842 | + | case WindowUpdate: | |||
| 843 | + | return p.applyWindowUpdate(m) | |||
| 844 | + | case *WindowUpdate: | |||
| 845 | + | if m == nil { | |||
| 846 | + | return ErrInvalidMessage | |||
| 847 | + | } | |||
| 848 | + | return p.applyClient(*m) | |||
| 765 | default: | 849 | default: | ||
| 766 | return ErrWrongDirection | 850 | return ErrWrongDirection | ||
| 767 | } | 851 | } | ||
| @@ -848,12 +932,20 @@ func (p *Pump) applyServer(message ServerMessage) error { | |||||
| 848 | return p.applyServer(*m) | 932 | return p.applyServer(*m) | ||
| 849 | case Reset: | 933 | case Reset: | ||
| 850 | disposition, err := p.engine.Reset(m) | 934 | disposition, err := p.engine.Reset(m) | ||
| 935 | + | p.logPeerReset(m, disposition) | |||
| 851 | return p.applyStreamTerminal(m.Physical, disposition, err) | 936 | return p.applyStreamTerminal(m.Physical, disposition, err) | ||
| 852 | case *Reset: | 937 | case *Reset: | ||
| 853 | if m == nil { | 938 | if m == nil { | ||
| 854 | return ErrInvalidMessage | 939 | return ErrInvalidMessage | ||
| 855 | } | 940 | } | ||
| 856 | return p.applyServer(*m) | 941 | return p.applyServer(*m) | ||
| 942 | + | case WindowUpdate: | |||
| 943 | + | return p.applyWindowUpdate(m) | |||
| 944 | + | case *WindowUpdate: | |||
| 945 | + | if m == nil { | |||
| 946 | + | return ErrInvalidMessage | |||
| 947 | + | } | |||
| 948 | + | return p.applyServer(*m) | |||
| 857 | default: | 949 | default: | ||
| 858 | return ErrWrongDirection | 950 | return ErrWrongDirection | ||
| 859 | } | 951 | } | ||
| @@ -876,6 +968,25 @@ func (p *Pump) applyStreamTerminal(physical PhysicalStreamID, disposition Stream | |||||
| 876 | return nil | 968 | return nil | ||
| 877 | } | 969 | } | ||
| 878 | 970 | ||||
| 971 | + | // logPeerReset records one stream the peer aborted, with the reason it sent. | |||
| 972 | + | // A late reset for an already settled stream is not logged. | |||
| 973 | + | func (p *Pump) logPeerReset(message Reset, disposition StreamDisposition) { | |||
| 974 | + | if disposition != StreamAccepted || !message.HasError { | |||
| 975 | + | return | |||
| 976 | + | } | |||
| 977 | + | slog.Warn("daemonmux_stream_reset", "side", p.local.String(), "stream", uint64(message.Physical), "initiator", "peer", "kind", message.Error.FailureKind.String(), "code", message.Error.Code.String(), "cause", message.Error.Text) | |||
| 978 | + | } | |||
| 979 | + | ||||
| 980 | + | // applyWindowUpdate grows one stream's send credit. A credit violation resets | |||
| 981 | + | // only that stream. | |||
| 982 | + | func (p *Pump) applyWindowUpdate(message WindowUpdate) error { | |||
| 983 | + | _, err := p.engine.WindowUpdate(message) | |||
| 984 | + | if err == nil { | |||
| 985 | + | return nil | |||
| 986 | + | } | |||
| 987 | + | return p.applyStreamError(message.Physical, err) | |||
| 988 | + | } | |||
| 989 | + | ||||
| 879 | // applyData queues one inbound chunk and classifies the engine's refusal. | 990 | // applyData queues one inbound chunk and classifies the engine's refusal. | ||
| 880 | func (p *Pump) applyData(message Data) error { | 991 | func (p *Pump) applyData(message Data) error { | ||
| 881 | _, err := p.engine.Data(message) | 992 | _, err := p.engine.Data(message) | ||
| @@ -917,6 +1028,7 @@ func (p *Pump) scheduleReset(physical PhysicalStreamID, cause error) { | |||||
| 917 | } | 1028 | } | ||
| 918 | detail := resetErrorDetail(cause) | 1029 | detail := resetErrorDetail(cause) | ||
| 919 | reset := Reset{Physical: physical, Error: detail, HasError: true} | 1030 | reset := Reset{Physical: physical, Error: detail, HasError: true} | ||
| 1031 | + | slog.Warn("daemonmux_stream_reset", "side", p.local.String(), "stream", uint64(physical), "initiator", "local", "kind", detail.FailureKind.String(), "cause", cause) | |||
| 920 | _, _ = p.engine.Reset(reset) | 1032 | _, _ = p.engine.Reset(reset) | ||
| 921 | var err error | 1033 | var err error | ||
| 922 | if p.local == DirectionServer { | 1034 | if p.local == DirectionServer { | ||
| @@ -941,6 +1053,7 @@ func (p *Pump) settleFailure(kind domain.RemoteFailureKind, cause error) { | |||||
| 941 | p.termMu.Lock() | 1053 | p.termMu.Lock() | ||
| 942 | if !p.settled { | 1054 | if !p.settled { | ||
| 943 | p.settled = true | 1055 | p.settled = true | ||
| 1056 | + | slog.Warn("daemonmux_physical_failed", "side", p.local.String(), "kind", kind.String(), "live_streams", p.engine.Live(), "cause", cause) | |||
| 944 | p.terminal.Fail(kind, cause) | 1057 | p.terminal.Fail(kind, cause) | ||
| 945 | p.engine.Fail(kind, cause) | 1058 | p.engine.Fail(kind, cause) | ||
| 946 | } | 1059 | } | ||
| @@ -1025,10 +1138,9 @@ func openRefusalDetail(cause error) ErrorDetail { | |||||
| 1025 | // response. | 1138 | // response. | ||
| 1026 | func resetErrorDetail(cause error) ErrorDetail { | 1139 | func resetErrorDetail(cause error) ErrorDetail { | ||
| 1027 | kind := domain.RemoteFailureInvalidResponse | 1140 | kind := domain.RemoteFailureInvalidResponse | ||
| 1028 | - | if errors.Is(cause, ErrStreamQueueFull) || errors.Is(cause, ErrSchedulerFull) { | 1141 | + | if errors.Is(cause, ErrSchedulerFull) { |
| 1029 | - | // A local byte-budget overflow is a transport-class abort in either | 1142 | + | // The local aggregate safety net: a transport-class abort, not an |
| 1030 | - | // direction: the stream was reset because a bounded queue could not | 1143 | + | // invalid peer frame. |
| 1031 | - | // hold the frame, not because the peer sent something invalid. | |||
| 1032 | kind = domain.RemoteFailureTransport | 1144 | kind = domain.RemoteFailureTransport | ||
| 1033 | } | 1145 | } | ||
| 1034 | return ErrorDetail{ | 1146 | return ErrorDetail{ | ||
M internal/adapters/daemonmux/pump_admission_test.go +1 -1
| @@ -24,7 +24,7 @@ func TestPumpApplyOpenDuplicateIsolation(t *testing.T) { | |||
| 24 | 24 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | |
| 25 | 25 | // A queued outbound frame proves the duplicate Open neither resets the | |
| 26 | 26 | // engine record nor discards the scheduler's queued work. | |
| 27 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte("out")}))) | |
| 27 | + | require.NoError(t, pump.SendData(1, []byte("out"), nil)) | |
| 28 | 28 | before := mustStatus(t, pump.Engine(), 1) | |
| 29 | 29 | watch := pump.Watch(1) | |
| 30 | 30 | ||
| @@ -24,7 +24,7 @@ func TestPumpApplyOpenDuplicateIsolation(t *testing.T) { | |||||
| 24 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | 24 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | ||
| 25 | // A queued outbound frame proves the duplicate Open neither resets the | 25 | // A queued outbound frame proves the duplicate Open neither resets the | ||
| 26 | // engine record nor discards the scheduler's queued work. | 26 | // engine record nor discards the scheduler's queued work. | ||
| 27 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte("out")}))) | 27 | + | require.NoError(t, pump.SendData(1, []byte("out"), nil)) |
| 28 | before := mustStatus(t, pump.Engine(), 1) | 28 | before := mustStatus(t, pump.Engine(), 1) | ||
| 29 | watch := pump.Watch(1) | 29 | watch := pump.Watch(1) | ||
| 30 | 30 | ||||
M internal/adapters/daemonmux/pump_test.go +135 -46
| @@ -296,7 +296,9 @@ func TestNewPumpValidation(t *testing.T) { | |||
| 296 | 296 | require.NoError(t, pump.Close()) | |
| 297 | 297 | require.True(t, channelClosed(pump.Done())) | |
| 298 | 298 | require.Equal(t, 1, carrier.closeCalls()) | |
| 299 | - | require.ErrorIs(t, pump.Send(Data{Physical: 1, Data: []byte("x")}), ErrPhysicalClosed) | |
| 299 | + | require.ErrorIs(t, pump.SendData(1, []byte("x"), nil), ErrPhysicalClosed) | |
| 300 | + | require.ErrorIs(t, pump.Send(Data{Physical: 1, Data: []byte("x")}), ErrDataNeedsCredit, "stream data never bypasses credit") | |
| 301 | + | require.ErrorIs(t, pump.Send(&Data{Physical: 1, Data: []byte("x")}), ErrDataNeedsCredit, "nor in pointer form") | |
| 300 | 302 | }) | |
| 301 | 303 | } | |
| 302 | 304 | ||
| @@ -329,7 +331,7 @@ func TestPumpTwoAndHundredStreams(t *testing.T) { | |||
| 329 | 331 | require.Equal(t, count-1, pump.Engine().AggregateBytes()) | |
| 330 | 332 | ||
| 331 | 333 | for _, id := range ids { | |
| 332 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: id, Data: []byte("out")}))) | |
| 334 | + | require.NoError(t, pump.SendData(id, []byte("out"), nil)) | |
| 333 | 335 | } | |
| 334 | 336 | seen := make(map[PhysicalStreamID]int, count) | |
| 335 | 337 | for i := 0; i < count; i++ { | |
| @@ -401,18 +403,20 @@ func TestPumpBlockedConsumerDoesNotBlockReaderOrSibling(t *testing.T) { | |||
| 401 | 403 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | |
| 402 | 404 | } | |
| 403 | 405 | ||
| 404 | - | // The consumer never Takes stream 1: its bounded queue fills. | |
| 405 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | |
| 406 | - | deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte{byte(i)}}) | |
| 406 | + | // A peer ignores flow control and fills stream 1's whole window while the | |
| 407 | + | // consumer never Takes it. | |
| 408 | + | chunk, full := floodWindow(pump.Ceilings()) | |
| 409 | + | for i := 0; i < full; i++ { | |
| 410 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk}) | |
| 407 | 411 | } | |
| 408 | 412 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | |
| 409 | 413 | status, ok := e.Status(1) | |
| 410 | - | return ok && status.QueuedChunks == MaxMuxStreamQueueChunks | |
| 414 | + | return ok && status.QueuedChunks == full | |
| 411 | 415 | }) | |
| 412 | 416 | ||
| 413 | - | // One more chunk overflows stream 1's own bound; a sibling chunk still | |
| 414 | - | // lands, so neither the reader nor the sibling was blocked. | |
| 415 | - | deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte{'x'}}) | |
| 417 | + | // One more chunk exceeds the granted window; a sibling chunk still lands, | |
| 418 | + | // so neither the reader nor the sibling was blocked. | |
| 419 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk}) | |
| 416 | 420 | deliverClientFrame(t, carrier, Data{Physical: 2, Data: []byte("sibling")}) | |
| 417 | 421 | ||
| 418 | 422 | taken, ok := takeEventually(t, pump, 2) | |
| @@ -422,7 +426,7 @@ func TestPumpBlockedConsumerDoesNotBlockReaderOrSibling(t *testing.T) { | |||
| 422 | 426 | stalled := mustStatus(t, pump.Engine(), 1) | |
| 423 | 427 | require.Equal(t, StreamTerminal, stalled.State) | |
| 424 | 428 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | |
| 425 | - | require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind) | |
| 429 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind) | |
| 426 | 430 | require.Zero(t, stalled.QueuedChunks) | |
| 427 | 431 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | |
| 428 | 432 | require.False(t, pump.Engine().Closed()) | |
| @@ -430,7 +434,7 @@ func TestPumpBlockedConsumerDoesNotBlockReaderOrSibling(t *testing.T) { | |||
| 430 | 434 | ||
| 431 | 435 | reset := decodeReset(t, carrier.nextSent(t), DirectionServer) | |
| 432 | 436 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | |
| 433 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | |
| 437 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, reset.Error.FailureKind) | |
| 434 | 438 | } | |
| 435 | 439 | ||
| 436 | 440 | // TestPumpBlockedWriterDoesNotBlockReader proves the reader keeps applying | |
| @@ -480,7 +484,7 @@ func TestPumpFairWriterProgress(t *testing.T) { | |||
| 480 | 484 | const rounds = 2 | |
| 481 | 485 | for round := 0; round < rounds; round++ { | |
| 482 | 486 | for _, id := range ids { | |
| 483 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: id, Data: []byte{byte(id), byte(round)}}))) | |
| 487 | + | require.NoError(t, pump.SendData(id, []byte{byte(id), byte(round)}, nil)) | |
| 484 | 488 | } | |
| 485 | 489 | } | |
| 486 | 490 | ||
| @@ -561,14 +565,15 @@ func TestPumpResetIsolation(t *testing.T) { | |||
| 561 | 565 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(id)})) | |
| 562 | 566 | } | |
| 563 | 567 | ||
| 564 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | |
| 565 | - | deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte{byte(i)}}) | |
| 568 | + | chunk, full := floodWindow(pump.Ceilings()) | |
| 569 | + | for i := 0; i < full; i++ { | |
| 570 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk}) | |
| 566 | 571 | } | |
| 567 | 572 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | |
| 568 | 573 | status, ok := e.Status(1) | |
| 569 | - | return ok && status.QueuedChunks == MaxMuxStreamQueueChunks | |
| 574 | + | return ok && status.QueuedChunks == full | |
| 570 | 575 | }) | |
| 571 | - | deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte{'x'}}) | |
| 576 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk}) | |
| 572 | 577 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | |
| 573 | 578 | status, ok := e.Status(1) | |
| 574 | 579 | return ok && status.State == StreamTerminal | |
| @@ -581,7 +586,7 @@ func TestPumpResetIsolation(t *testing.T) { | |||
| 581 | 586 | ||
| 582 | 587 | reset := decodeReset(t, carrier.nextSent(t), DirectionServer) | |
| 583 | 588 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | |
| 584 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | |
| 589 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, reset.Error.FailureKind) | |
| 585 | 590 | require.False(t, pump.Engine().Closed()) | |
| 586 | 591 | }) | |
| 587 | 592 | } | |
| @@ -767,7 +772,7 @@ func TestPumpConcurrentClose(t *testing.T) { | |||
| 767 | 772 | require.Equal(t, 1, carrier.closeCalls()) | |
| 768 | 773 | require.True(t, channelClosed(pump.readerDone)) | |
| 769 | 774 | require.True(t, channelClosed(pump.writerDone)) | |
| 770 | - | require.ErrorIs(t, pump.Send(Data{Physical: 1, Data: []byte("x")}), ErrPhysicalClosed) | |
| 775 | + | require.ErrorIs(t, pump.SendData(1, []byte("x"), nil), ErrPhysicalClosed) | |
| 771 | 776 | } | |
| 772 | 777 | ||
| 773 | 778 | // TestPumpCloseBeforeStart proves Close never starts or joins goroutines that | |
| @@ -891,7 +896,7 @@ func TestPumpFlushHonoursContext(t *testing.T) { | |||
| 891 | 896 | require.NoError(t, pump.Engine().Open(Open{Ref: testRef(1)})) | |
| 892 | 897 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | |
| 893 | 898 | // No writer is started, so the queued frame can never drain. | |
| 894 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte("queued")}))) | |
| 899 | + | require.NoError(t, pump.SendData(1, []byte("queued"), nil)) | |
| 895 | 900 | ||
| 896 | 901 | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |
| 897 | 902 | defer cancel() | |
| @@ -919,7 +924,7 @@ func TestPumpFlushCancellationMayDrop(t *testing.T) { | |||
| 919 | 924 | ||
| 920 | 925 | hold := make(chan struct{}) | |
| 921 | 926 | carrier.setHold(hold) | |
| 922 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte("first")}))) | |
| 927 | + | require.NoError(t, pump.SendData(1, []byte("first"), nil)) | |
| 923 | 928 | require.Eventually(t, func() bool { return carrier.enteredSends() >= 1 }, 5*time.Second, time.Millisecond) | |
| 924 | 929 | // The final Close is queued behind the blocked write. | |
| 925 | 930 | require.NoError(t, pump.Send(ServerMessage(Close{Physical: 1}))) | |
| @@ -930,38 +935,122 @@ func TestPumpFlushCancellationMayDrop(t *testing.T) { | |||
| 930 | 935 | carrier.requireNoSent(t, 50*time.Millisecond) | |
| 931 | 936 | } | |
| 932 | 937 | ||
| 933 | - | // TestPumpOutboundOverflowSchedulesReset proves an outbound queue overflow | |
| 934 | - | // discards the stream's queued frames, resets its engine record to free the | |
| 935 | - | // live admission slot, and schedules exactly one Reset for the peer. | |
| 936 | - | func TestPumpOutboundOverflowSchedulesReset(t *testing.T) { | |
| 938 | + | // TestPumpSendDataBackpressure proves a sender that exhausted its stream | |
| 939 | + | // credit waits instead of overflowing or resetting the stream, resumes once | |
| 940 | + | // the peer returns credit, and that the stream stays open throughout. | |
| 941 | + | func TestPumpSendDataBackpressure(t *testing.T) { | |
| 937 | 942 | pump, carrier := newTestPump(t, DirectionClient) | |
| 938 | - | require.NoError(t, pump.Engine().Open(Open{Ref: testRef(1)})) | |
| 943 | + | pump.Start(context.Background()) | |
| 944 | + | deliverClientFrame(t, carrier, openFor(1)) | |
| 945 | + | requireEngineEventually(t, pump, func(e *StreamEngine) bool { return e.Live() == 1 }) | |
| 939 | 946 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | |
| 940 | 947 | ||
| 941 | - | // Fill the per-stream frame bound with no writer draining it. | |
| 942 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | |
| 943 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte{byte(i)}}))) | |
| 948 | + | chunk, full := floodWindow(pump.Ceilings()) | |
| 949 | + | for i := 0; i < full; i++ { | |
| 950 | + | require.NoError(t, pump.SendData(1, chunk, nil)) | |
| 944 | 951 | } | |
| 945 | - | require.Equal(t, MaxMuxStreamQueueChunks, pump.scheduler.QueuedFrames(1)) | |
| 946 | - | ||
| 947 | - | require.ErrorIs(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte("overflow")})), ErrSchedulerFull) | |
| 948 | - | require.Equal(t, 0, pump.Engine().Live(), "the overflowed stream released its admission slot") | |
| 949 | - | status := mustStatus(t, pump.Engine(), 1) | |
| 950 | - | require.Equal(t, StreamTerminal, status.State) | |
| 951 | - | require.ErrorContains(t, status.Err, "scheduler queue overflow") | |
| 952 | - | require.Equal(t, 1, pump.scheduler.QueuedFrames(1), "exactly the one Reset remains queued") | |
| 953 | - | require.False(t, pump.Engine().Closed()) | |
| 952 | + | for i := 0; i < full; i++ { | |
| 953 | + | carrier.nextSent(t) | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | sent := make(chan error, 1) | |
| 957 | + | go func() { sent <- pump.SendData(1, chunk, nil) }() | |
| 958 | + | select { | |
| 959 | + | case err := <-sent: | |
| 960 | + | t.Fatalf("SendData returned %v without credit", err) | |
| 961 | + | case <-time.After(50 * time.Millisecond): | |
| 962 | + | } | |
| 963 | + | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 1).State, "a slow peer never resets the stream") | |
| 954 | 964 | ||
| 965 | + | deliverClientFrame(t, carrier, WindowUpdate{Physical: 1, Credit: chunkCredit(len(chunk))}) | |
| 966 | + | require.NoError(t, <-sent) | |
| 967 | + | message, err := DecodeServer(carrier.nextSent(t), testEnvelopeCeiling, testChunkCeiling) | |
| 968 | + | require.NoError(t, err) | |
| 969 | + | require.Equal(t, Data{Physical: 1, Data: chunk}, message) | |
| 970 | + | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 1).State) | |
| 971 | + | } | |
| 972 | + | ||
| 973 | + | // TestPumpSendDataCancel proves a sender blocked on credit returns when its | |
| 974 | + | // cancel channel closes or its stream settles. | |
| 975 | + | func TestPumpSendDataCancel(t *testing.T) { | |
| 976 | + | cases := []struct { | |
| 977 | + | name string | |
| 978 | + | settle func(*testing.T, *Pump, *fakeCarrier, chan struct{}) | |
| 979 | + | want error | |
| 980 | + | }{ | |
| 981 | + | {"cancel", func(_ *testing.T, _ *Pump, _ *fakeCarrier, cancel chan struct{}) { close(cancel) }, ErrLogicalClosed}, | |
| 982 | + | {"peer reset", func(t *testing.T, _ *Pump, carrier *fakeCarrier, _ chan struct{}) { | |
| 983 | + | deliverClientFrame(t, carrier, Reset{Physical: 1}) | |
| 984 | + | }, errStreamSettled}, | |
| 985 | + | {"peer close with unread data", func(t *testing.T, pump *Pump, carrier *fakeCarrier, _ chan struct{}) { | |
| 986 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte("unread")}) | |
| 987 | + | deliverClientFrame(t, carrier, Close{Physical: 1}) | |
| 988 | + | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | |
| 989 | + | status, ok := e.Status(1) | |
| 990 | + | return ok && status.State == StreamClosing | |
| 991 | + | }) | |
| 992 | + | }, errStreamSettled}, | |
| 993 | + | {"physical failure", func(t *testing.T, _ *Pump, carrier *fakeCarrier, _ chan struct{}) { | |
| 994 | + | carrier.deliver(t, fakeFrame{err: errors.New("carriage lost")}) | |
| 995 | + | }, ErrPhysicalClosed}, | |
| 996 | + | {"credit beyond window resets the stream", func(t *testing.T, pump *Pump, carrier *fakeCarrier, _ chan struct{}) { | |
| 997 | + | deliverClientFrame(t, carrier, WindowUpdate{Physical: 1, Credit: pump.Ceilings().StreamWindow()}) | |
| 998 | + | }, errStreamSettled}, | |
| 999 | + | } | |
| 1000 | + | for _, tc := range cases { | |
| 1001 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1002 | + | pump, carrier := newTestPump(t, DirectionClient) | |
| 1003 | + | pump.Start(context.Background()) | |
| 1004 | + | deliverClientFrame(t, carrier, openFor(1)) | |
| 1005 | + | requireEngineEventually(t, pump, func(e *StreamEngine) bool { return e.Live() == 1 }) | |
| 1006 | + | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | |
| 1007 | + | chunk, full := floodWindow(pump.Ceilings()) | |
| 1008 | + | for i := 0; i < full; i++ { | |
| 1009 | + | require.NoError(t, pump.SendData(1, chunk, nil)) | |
| 1010 | + | } | |
| 1011 | + | ||
| 1012 | + | cancel := make(chan struct{}) | |
| 1013 | + | sent := make(chan error, 1) | |
| 1014 | + | go func() { sent <- pump.SendData(1, chunk, cancel) }() | |
| 1015 | + | tc.settle(t, pump, carrier, cancel) | |
| 1016 | + | select { | |
| 1017 | + | case err := <-sent: | |
| 1018 | + | require.ErrorIs(t, err, tc.want) | |
| 1019 | + | case <-time.After(5 * time.Second): | |
| 1020 | + | t.Fatal("blocked SendData never returned") | |
| 1021 | + | } | |
| 1022 | + | }) | |
| 1023 | + | } | |
| 1024 | + | } | |
| 1025 | + | ||
| 1026 | + | // TestPumpReturnsCreditInBatches proves the receiver grants consumed credit | |
| 1027 | + | // back with one WindowUpdate per batch, not one per chunk. | |
| 1028 | + | func TestPumpReturnsCreditInBatches(t *testing.T) { | |
| 1029 | + | pump, carrier := newTestPump(t, DirectionClient) | |
| 955 | 1030 | pump.Start(context.Background()) | |
| 956 | - | reset := decodeReset(t, carrier.nextSent(t), DirectionServer) | |
| 957 | - | require.Equal(t, PhysicalStreamID(1), reset.Physical) | |
| 958 | - | require.True(t, reset.HasError) | |
| 959 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | |
| 1031 | + | deliverClientFrame(t, carrier, openFor(1)) | |
| 1032 | + | requireEngineEventually(t, pump, func(e *StreamEngine) bool { return e.Live() == 1 }) | |
| 1033 | + | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | |
| 960 | 1034 | ||
| 961 | - | // The record is sealed after its one reset: a later frame for the retired | |
| 962 | - | // stream is refused and never dequeued again. | |
| 963 | - | require.ErrorIs(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte("late")})), ErrSchedulerSettled) | |
| 964 | - | carrier.requireNoSent(t, 50*time.Millisecond) | |
| 1035 | + | chunk, full := floodWindow(pump.Ceilings()) | |
| 1036 | + | for i := 0; i < full; i++ { | |
| 1037 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk}) | |
| 1038 | + | } | |
| 1039 | + | var granted uint64 | |
| 1040 | + | updates := 0 | |
| 1041 | + | for i := 0; i < full; i++ { | |
| 1042 | + | _, ok := takeEventually(t, pump, 1) | |
| 1043 | + | require.True(t, ok) | |
| 1044 | + | } | |
| 1045 | + | for granted < pump.Ceilings().creditReturnThreshold() { | |
| 1046 | + | message, err := DecodeServer(carrier.nextSent(t), testEnvelopeCeiling, testChunkCeiling) | |
| 1047 | + | require.NoError(t, err) | |
| 1048 | + | update, ok := message.(WindowUpdate) | |
| 1049 | + | require.True(t, ok, "got %T", message) | |
| 1050 | + | granted += update.Credit | |
| 1051 | + | updates++ | |
| 1052 | + | } | |
| 1053 | + | require.Less(t, updates, full, "credit is batched") | |
| 965 | 1054 | } | |
| 966 | 1055 | ||
| 967 | 1056 | // TestPumpInboundTerminalRetiresQueuedOutbound proves an inbound Close or Reset | |
| @@ -975,7 +1064,7 @@ func TestPumpInboundTerminalRetiresQueuedOutbound(t *testing.T) { | |||
| 975 | 1064 | id := PhysicalStreamID(i) | |
| 976 | 1065 | require.NoError(t, pump.Engine().Open(Open{Ref: testRef(id)})) | |
| 977 | 1066 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(id)})) | |
| 978 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: id, Data: []byte("out")}))) | |
| 1067 | + | require.NoError(t, pump.SendData(id, []byte("out"), nil)) | |
| 979 | 1068 | require.Equal(t, 1, pump.scheduler.QueuedFrames(id)) | |
| 980 | 1069 | ||
| 981 | 1070 | if i%2 == 0 { | |
| @@ -296,7 +296,9 @@ func TestNewPumpValidation(t *testing.T) { | |||||
| 296 | require.NoError(t, pump.Close()) | 296 | require.NoError(t, pump.Close()) | ||
| 297 | require.True(t, channelClosed(pump.Done())) | 297 | require.True(t, channelClosed(pump.Done())) | ||
| 298 | require.Equal(t, 1, carrier.closeCalls()) | 298 | require.Equal(t, 1, carrier.closeCalls()) | ||
| 299 | - | require.ErrorIs(t, pump.Send(Data{Physical: 1, Data: []byte("x")}), ErrPhysicalClosed) | 299 | + | require.ErrorIs(t, pump.SendData(1, []byte("x"), nil), ErrPhysicalClosed) |
| 300 | + | require.ErrorIs(t, pump.Send(Data{Physical: 1, Data: []byte("x")}), ErrDataNeedsCredit, "stream data never bypasses credit") | |||
| 301 | + | require.ErrorIs(t, pump.Send(&Data{Physical: 1, Data: []byte("x")}), ErrDataNeedsCredit, "nor in pointer form") | |||
| 300 | }) | 302 | }) | ||
| 301 | } | 303 | } | ||
| 302 | 304 | ||||
| @@ -329,7 +331,7 @@ func TestPumpTwoAndHundredStreams(t *testing.T) { | |||||
| 329 | require.Equal(t, count-1, pump.Engine().AggregateBytes()) | 331 | require.Equal(t, count-1, pump.Engine().AggregateBytes()) | ||
| 330 | 332 | ||||
| 331 | for _, id := range ids { | 333 | for _, id := range ids { | ||
| 332 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: id, Data: []byte("out")}))) | 334 | + | require.NoError(t, pump.SendData(id, []byte("out"), nil)) |
| 333 | } | 335 | } | ||
| 334 | seen := make(map[PhysicalStreamID]int, count) | 336 | seen := make(map[PhysicalStreamID]int, count) | ||
| 335 | for i := 0; i < count; i++ { | 337 | for i := 0; i < count; i++ { | ||
| @@ -401,18 +403,20 @@ func TestPumpBlockedConsumerDoesNotBlockReaderOrSibling(t *testing.T) { | |||||
| 401 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | 403 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | ||
| 402 | } | 404 | } | ||
| 403 | 405 | ||||
| 404 | - | // The consumer never Takes stream 1: its bounded queue fills. | 406 | + | // A peer ignores flow control and fills stream 1's whole window while the |
| 405 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | 407 | + | // consumer never Takes it. |
| 406 | - | deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte{byte(i)}}) | 408 | + | chunk, full := floodWindow(pump.Ceilings()) |
| 409 | + | for i := 0; i < full; i++ { | |||
| 410 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk}) | |||
| 407 | } | 411 | } | ||
| 408 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | 412 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | ||
| 409 | status, ok := e.Status(1) | 413 | status, ok := e.Status(1) | ||
| 410 | - | return ok && status.QueuedChunks == MaxMuxStreamQueueChunks | 414 | + | return ok && status.QueuedChunks == full |
| 411 | }) | 415 | }) | ||
| 412 | 416 | ||||
| 413 | - | // One more chunk overflows stream 1's own bound; a sibling chunk still | 417 | + | // One more chunk exceeds the granted window; a sibling chunk still lands, |
| 414 | - | // lands, so neither the reader nor the sibling was blocked. | 418 | + | // so neither the reader nor the sibling was blocked. |
| 415 | - | deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte{'x'}}) | 419 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk}) |
| 416 | deliverClientFrame(t, carrier, Data{Physical: 2, Data: []byte("sibling")}) | 420 | deliverClientFrame(t, carrier, Data{Physical: 2, Data: []byte("sibling")}) | ||
| 417 | 421 | ||||
| 418 | taken, ok := takeEventually(t, pump, 2) | 422 | taken, ok := takeEventually(t, pump, 2) | ||
| @@ -422,7 +426,7 @@ func TestPumpBlockedConsumerDoesNotBlockReaderOrSibling(t *testing.T) { | |||||
| 422 | stalled := mustStatus(t, pump.Engine(), 1) | 426 | stalled := mustStatus(t, pump.Engine(), 1) | ||
| 423 | require.Equal(t, StreamTerminal, stalled.State) | 427 | require.Equal(t, StreamTerminal, stalled.State) | ||
| 424 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | 428 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | ||
| 425 | - | require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind) | 429 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind) |
| 426 | require.Zero(t, stalled.QueuedChunks) | 430 | require.Zero(t, stalled.QueuedChunks) | ||
| 427 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | 431 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | ||
| 428 | require.False(t, pump.Engine().Closed()) | 432 | require.False(t, pump.Engine().Closed()) | ||
| @@ -430,7 +434,7 @@ func TestPumpBlockedConsumerDoesNotBlockReaderOrSibling(t *testing.T) { | |||||
| 430 | 434 | ||||
| 431 | reset := decodeReset(t, carrier.nextSent(t), DirectionServer) | 435 | reset := decodeReset(t, carrier.nextSent(t), DirectionServer) | ||
| 432 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | 436 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | ||
| 433 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | 437 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, reset.Error.FailureKind) |
| 434 | } | 438 | } | ||
| 435 | 439 | ||||
| 436 | // TestPumpBlockedWriterDoesNotBlockReader proves the reader keeps applying | 440 | // TestPumpBlockedWriterDoesNotBlockReader proves the reader keeps applying | ||
| @@ -480,7 +484,7 @@ func TestPumpFairWriterProgress(t *testing.T) { | |||||
| 480 | const rounds = 2 | 484 | const rounds = 2 | ||
| 481 | for round := 0; round < rounds; round++ { | 485 | for round := 0; round < rounds; round++ { | ||
| 482 | for _, id := range ids { | 486 | for _, id := range ids { | ||
| 483 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: id, Data: []byte{byte(id), byte(round)}}))) | 487 | + | require.NoError(t, pump.SendData(id, []byte{byte(id), byte(round)}, nil)) |
| 484 | } | 488 | } | ||
| 485 | } | 489 | } | ||
| 486 | 490 | ||||
| @@ -561,14 +565,15 @@ func TestPumpResetIsolation(t *testing.T) { | |||||
| 561 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(id)})) | 565 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(id)})) | ||
| 562 | } | 566 | } | ||
| 563 | 567 | ||||
| 564 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | 568 | + | chunk, full := floodWindow(pump.Ceilings()) |
| 565 | - | deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte{byte(i)}}) | 569 | + | for i := 0; i < full; i++ { |
| 570 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk}) | |||
| 566 | } | 571 | } | ||
| 567 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | 572 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | ||
| 568 | status, ok := e.Status(1) | 573 | status, ok := e.Status(1) | ||
| 569 | - | return ok && status.QueuedChunks == MaxMuxStreamQueueChunks | 574 | + | return ok && status.QueuedChunks == full |
| 570 | }) | 575 | }) | ||
| 571 | - | deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte{'x'}}) | 576 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk}) |
| 572 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | 577 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | ||
| 573 | status, ok := e.Status(1) | 578 | status, ok := e.Status(1) | ||
| 574 | return ok && status.State == StreamTerminal | 579 | return ok && status.State == StreamTerminal | ||
| @@ -581,7 +586,7 @@ func TestPumpResetIsolation(t *testing.T) { | |||||
| 581 | 586 | ||||
| 582 | reset := decodeReset(t, carrier.nextSent(t), DirectionServer) | 587 | reset := decodeReset(t, carrier.nextSent(t), DirectionServer) | ||
| 583 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | 588 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | ||
| 584 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | 589 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, reset.Error.FailureKind) |
| 585 | require.False(t, pump.Engine().Closed()) | 590 | require.False(t, pump.Engine().Closed()) | ||
| 586 | }) | 591 | }) | ||
| 587 | } | 592 | } | ||
| @@ -767,7 +772,7 @@ func TestPumpConcurrentClose(t *testing.T) { | |||||
| 767 | require.Equal(t, 1, carrier.closeCalls()) | 772 | require.Equal(t, 1, carrier.closeCalls()) | ||
| 768 | require.True(t, channelClosed(pump.readerDone)) | 773 | require.True(t, channelClosed(pump.readerDone)) | ||
| 769 | require.True(t, channelClosed(pump.writerDone)) | 774 | require.True(t, channelClosed(pump.writerDone)) | ||
| 770 | - | require.ErrorIs(t, pump.Send(Data{Physical: 1, Data: []byte("x")}), ErrPhysicalClosed) | 775 | + | require.ErrorIs(t, pump.SendData(1, []byte("x"), nil), ErrPhysicalClosed) |
| 771 | } | 776 | } | ||
| 772 | 777 | ||||
| 773 | // TestPumpCloseBeforeStart proves Close never starts or joins goroutines that | 778 | // TestPumpCloseBeforeStart proves Close never starts or joins goroutines that | ||
| @@ -891,7 +896,7 @@ func TestPumpFlushHonoursContext(t *testing.T) { | |||||
| 891 | require.NoError(t, pump.Engine().Open(Open{Ref: testRef(1)})) | 896 | require.NoError(t, pump.Engine().Open(Open{Ref: testRef(1)})) | ||
| 892 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | 897 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | ||
| 893 | // No writer is started, so the queued frame can never drain. | 898 | // No writer is started, so the queued frame can never drain. | ||
| 894 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte("queued")}))) | 899 | + | require.NoError(t, pump.SendData(1, []byte("queued"), nil)) |
| 895 | 900 | ||||
| 896 | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | 901 | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | ||
| 897 | defer cancel() | 902 | defer cancel() | ||
| @@ -919,7 +924,7 @@ func TestPumpFlushCancellationMayDrop(t *testing.T) { | |||||
| 919 | 924 | ||||
| 920 | hold := make(chan struct{}) | 925 | hold := make(chan struct{}) | ||
| 921 | carrier.setHold(hold) | 926 | carrier.setHold(hold) | ||
| 922 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte("first")}))) | 927 | + | require.NoError(t, pump.SendData(1, []byte("first"), nil)) |
| 923 | require.Eventually(t, func() bool { return carrier.enteredSends() >= 1 }, 5*time.Second, time.Millisecond) | 928 | require.Eventually(t, func() bool { return carrier.enteredSends() >= 1 }, 5*time.Second, time.Millisecond) | ||
| 924 | // The final Close is queued behind the blocked write. | 929 | // The final Close is queued behind the blocked write. | ||
| 925 | require.NoError(t, pump.Send(ServerMessage(Close{Physical: 1}))) | 930 | require.NoError(t, pump.Send(ServerMessage(Close{Physical: 1}))) | ||
| @@ -930,38 +935,122 @@ func TestPumpFlushCancellationMayDrop(t *testing.T) { | |||||
| 930 | carrier.requireNoSent(t, 50*time.Millisecond) | 935 | carrier.requireNoSent(t, 50*time.Millisecond) | ||
| 931 | } | 936 | } | ||
| 932 | 937 | ||||
| 933 | - | // TestPumpOutboundOverflowSchedulesReset proves an outbound queue overflow | 938 | + | // TestPumpSendDataBackpressure proves a sender that exhausted its stream |
| 934 | - | // discards the stream's queued frames, resets its engine record to free the | 939 | + | // credit waits instead of overflowing or resetting the stream, resumes once |
| 935 | - | // live admission slot, and schedules exactly one Reset for the peer. | 940 | + | // the peer returns credit, and that the stream stays open throughout. |
| 936 | - | func TestPumpOutboundOverflowSchedulesReset(t *testing.T) { | 941 | + | func TestPumpSendDataBackpressure(t *testing.T) { |
| 937 | pump, carrier := newTestPump(t, DirectionClient) | 942 | pump, carrier := newTestPump(t, DirectionClient) | ||
| 938 | - | require.NoError(t, pump.Engine().Open(Open{Ref: testRef(1)})) | 943 | + | pump.Start(context.Background()) |
| 944 | + | deliverClientFrame(t, carrier, openFor(1)) | |||
| 945 | + | requireEngineEventually(t, pump, func(e *StreamEngine) bool { return e.Live() == 1 }) | |||
| 939 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | 946 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | ||
| 940 | 947 | ||||
| 941 | - | // Fill the per-stream frame bound with no writer draining it. | 948 | + | chunk, full := floodWindow(pump.Ceilings()) |
| 942 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | 949 | + | for i := 0; i < full; i++ { |
| 943 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte{byte(i)}}))) | 950 | + | require.NoError(t, pump.SendData(1, chunk, nil)) |
| 944 | } | 951 | } | ||
| 945 | - | require.Equal(t, MaxMuxStreamQueueChunks, pump.scheduler.QueuedFrames(1)) | 952 | + | for i := 0; i < full; i++ { |
| 946 | - | 953 | + | carrier.nextSent(t) | |
| 947 | - | require.ErrorIs(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte("overflow")})), ErrSchedulerFull) | 954 | + | } |
| 948 | - | require.Equal(t, 0, pump.Engine().Live(), "the overflowed stream released its admission slot") | 955 | + | |
| 949 | - | status := mustStatus(t, pump.Engine(), 1) | 956 | + | sent := make(chan error, 1) |
| 950 | - | require.Equal(t, StreamTerminal, status.State) | 957 | + | go func() { sent <- pump.SendData(1, chunk, nil) }() |
| 951 | - | require.ErrorContains(t, status.Err, "scheduler queue overflow") | 958 | + | select { |
| 952 | - | require.Equal(t, 1, pump.scheduler.QueuedFrames(1), "exactly the one Reset remains queued") | 959 | + | case err := <-sent: |
| 953 | - | require.False(t, pump.Engine().Closed()) | 960 | + | t.Fatalf("SendData returned %v without credit", err) |
| 961 | + | case <-time.After(50 * time.Millisecond): | |||
| 962 | + | } | |||
| 963 | + | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 1).State, "a slow peer never resets the stream") | |||
| 954 | 964 | ||||
| 965 | + | deliverClientFrame(t, carrier, WindowUpdate{Physical: 1, Credit: chunkCredit(len(chunk))}) | |||
| 966 | + | require.NoError(t, <-sent) | |||
| 967 | + | message, err := DecodeServer(carrier.nextSent(t), testEnvelopeCeiling, testChunkCeiling) | |||
| 968 | + | require.NoError(t, err) | |||
| 969 | + | require.Equal(t, Data{Physical: 1, Data: chunk}, message) | |||
| 970 | + | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 1).State) | |||
| 971 | + | } | |||
| 972 | + | ||||
| 973 | + | // TestPumpSendDataCancel proves a sender blocked on credit returns when its | |||
| 974 | + | // cancel channel closes or its stream settles. | |||
| 975 | + | func TestPumpSendDataCancel(t *testing.T) { | |||
| 976 | + | cases := []struct { | |||
| 977 | + | name string | |||
| 978 | + | settle func(*testing.T, *Pump, *fakeCarrier, chan struct{}) | |||
| 979 | + | want error | |||
| 980 | + | }{ | |||
| 981 | + | {"cancel", func(_ *testing.T, _ *Pump, _ *fakeCarrier, cancel chan struct{}) { close(cancel) }, ErrLogicalClosed}, | |||
| 982 | + | {"peer reset", func(t *testing.T, _ *Pump, carrier *fakeCarrier, _ chan struct{}) { | |||
| 983 | + | deliverClientFrame(t, carrier, Reset{Physical: 1}) | |||
| 984 | + | }, errStreamSettled}, | |||
| 985 | + | {"peer close with unread data", func(t *testing.T, pump *Pump, carrier *fakeCarrier, _ chan struct{}) { | |||
| 986 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte("unread")}) | |||
| 987 | + | deliverClientFrame(t, carrier, Close{Physical: 1}) | |||
| 988 | + | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | |||
| 989 | + | status, ok := e.Status(1) | |||
| 990 | + | return ok && status.State == StreamClosing | |||
| 991 | + | }) | |||
| 992 | + | }, errStreamSettled}, | |||
| 993 | + | {"physical failure", func(t *testing.T, _ *Pump, carrier *fakeCarrier, _ chan struct{}) { | |||
| 994 | + | carrier.deliver(t, fakeFrame{err: errors.New("carriage lost")}) | |||
| 995 | + | }, ErrPhysicalClosed}, | |||
| 996 | + | {"credit beyond window resets the stream", func(t *testing.T, pump *Pump, carrier *fakeCarrier, _ chan struct{}) { | |||
| 997 | + | deliverClientFrame(t, carrier, WindowUpdate{Physical: 1, Credit: pump.Ceilings().StreamWindow()}) | |||
| 998 | + | }, errStreamSettled}, | |||
| 999 | + | } | |||
| 1000 | + | for _, tc := range cases { | |||
| 1001 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1002 | + | pump, carrier := newTestPump(t, DirectionClient) | |||
| 1003 | + | pump.Start(context.Background()) | |||
| 1004 | + | deliverClientFrame(t, carrier, openFor(1)) | |||
| 1005 | + | requireEngineEventually(t, pump, func(e *StreamEngine) bool { return e.Live() == 1 }) | |||
| 1006 | + | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) | |||
| 1007 | + | chunk, full := floodWindow(pump.Ceilings()) | |||
| 1008 | + | for i := 0; i < full; i++ { | |||
| 1009 | + | require.NoError(t, pump.SendData(1, chunk, nil)) | |||
| 1010 | + | } | |||
| 1011 | + | ||||
| 1012 | + | cancel := make(chan struct{}) | |||
| 1013 | + | sent := make(chan error, 1) | |||
| 1014 | + | go func() { sent <- pump.SendData(1, chunk, cancel) }() | |||
| 1015 | + | tc.settle(t, pump, carrier, cancel) | |||
| 1016 | + | select { | |||
| 1017 | + | case err := <-sent: | |||
| 1018 | + | require.ErrorIs(t, err, tc.want) | |||
| 1019 | + | case <-time.After(5 * time.Second): | |||
| 1020 | + | t.Fatal("blocked SendData never returned") | |||
| 1021 | + | } | |||
| 1022 | + | }) | |||
| 1023 | + | } | |||
| 1024 | + | } | |||
| 1025 | + | ||||
| 1026 | + | // TestPumpReturnsCreditInBatches proves the receiver grants consumed credit | |||
| 1027 | + | // back with one WindowUpdate per batch, not one per chunk. | |||
| 1028 | + | func TestPumpReturnsCreditInBatches(t *testing.T) { | |||
| 1029 | + | pump, carrier := newTestPump(t, DirectionClient) | |||
| 955 | pump.Start(context.Background()) | 1030 | pump.Start(context.Background()) | ||
| 956 | - | reset := decodeReset(t, carrier.nextSent(t), DirectionServer) | 1031 | + | deliverClientFrame(t, carrier, openFor(1)) |
| 957 | - | require.Equal(t, PhysicalStreamID(1), reset.Physical) | 1032 | + | requireEngineEventually(t, pump, func(e *StreamEngine) bool { return e.Live() == 1 }) |
| 958 | - | require.True(t, reset.HasError) | 1033 | + | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)})) |
| 959 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | |||
| 960 | 1034 | ||||
| 961 | - | // The record is sealed after its one reset: a later frame for the retired | 1035 | + | chunk, full := floodWindow(pump.Ceilings()) |
| 962 | - | // stream is refused and never dequeued again. | 1036 | + | for i := 0; i < full; i++ { |
| 963 | - | require.ErrorIs(t, pump.Send(ServerMessage(Data{Physical: 1, Data: []byte("late")})), ErrSchedulerSettled) | 1037 | + | deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk}) |
| 964 | - | carrier.requireNoSent(t, 50*time.Millisecond) | 1038 | + | } |
| 1039 | + | var granted uint64 | |||
| 1040 | + | updates := 0 | |||
| 1041 | + | for i := 0; i < full; i++ { | |||
| 1042 | + | _, ok := takeEventually(t, pump, 1) | |||
| 1043 | + | require.True(t, ok) | |||
| 1044 | + | } | |||
| 1045 | + | for granted < pump.Ceilings().creditReturnThreshold() { | |||
| 1046 | + | message, err := DecodeServer(carrier.nextSent(t), testEnvelopeCeiling, testChunkCeiling) | |||
| 1047 | + | require.NoError(t, err) | |||
| 1048 | + | update, ok := message.(WindowUpdate) | |||
| 1049 | + | require.True(t, ok, "got %T", message) | |||
| 1050 | + | granted += update.Credit | |||
| 1051 | + | updates++ | |||
| 1052 | + | } | |||
| 1053 | + | require.Less(t, updates, full, "credit is batched") | |||
| 965 | } | 1054 | } | ||
| 966 | 1055 | ||||
| 967 | // TestPumpInboundTerminalRetiresQueuedOutbound proves an inbound Close or Reset | 1056 | // TestPumpInboundTerminalRetiresQueuedOutbound proves an inbound Close or Reset | ||
| @@ -975,7 +1064,7 @@ func TestPumpInboundTerminalRetiresQueuedOutbound(t *testing.T) { | |||||
| 975 | id := PhysicalStreamID(i) | 1064 | id := PhysicalStreamID(i) | ||
| 976 | require.NoError(t, pump.Engine().Open(Open{Ref: testRef(id)})) | 1065 | require.NoError(t, pump.Engine().Open(Open{Ref: testRef(id)})) | ||
| 977 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(id)})) | 1066 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(id)})) | ||
| 978 | - | require.NoError(t, pump.Send(ServerMessage(Data{Physical: id, Data: []byte("out")}))) | 1067 | + | require.NoError(t, pump.SendData(id, []byte("out"), nil)) |
| 979 | require.Equal(t, 1, pump.scheduler.QueuedFrames(id)) | 1068 | require.Equal(t, 1, pump.scheduler.QueuedFrames(id)) | ||
| 980 | 1069 | ||||
| 981 | if i%2 == 0 { | 1070 | if i%2 == 0 { | ||
M internal/adapters/daemonmux/quic_carriage_test.go +11 -9
| @@ -273,18 +273,20 @@ func TestQUICCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||
| 273 | 273 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | |
| 274 | 274 | } | |
| 275 | 275 | ||
| 276 | - | // The consumer never Takes stream 1: its bounded queue fills. | |
| 277 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | |
| 278 | - | injectQUICClientFrame(t, client, Data{Physical: 1, Data: []byte{byte(i)}}) | |
| 276 | + | // A peer ignores flow control and fills stream 1's whole window while the | |
| 277 | + | // consumer never Takes it. | |
| 278 | + | chunk, full := floodWindow(pump.Ceilings()) | |
| 279 | + | for i := 0; i < full; i++ { | |
| 280 | + | injectQUICClientFrame(t, client, Data{Physical: 1, Data: chunk}) | |
| 279 | 281 | } | |
| 280 | 282 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | |
| 281 | 283 | status, ok := e.Status(1) | |
| 282 | - | return ok && status.QueuedChunks == MaxMuxStreamQueueChunks | |
| 284 | + | return ok && status.QueuedChunks == full | |
| 283 | 285 | }) | |
| 284 | 286 | ||
| 285 | - | // One more chunk overflows stream 1's own bound; a sibling chunk still | |
| 286 | - | // lands, so neither the reader nor the sibling was blocked. | |
| 287 | - | injectQUICClientFrame(t, client, Data{Physical: 1, Data: []byte{'x'}}) | |
| 287 | + | // One more chunk exceeds the granted window; a sibling chunk still lands, | |
| 288 | + | // so neither the reader nor the sibling was blocked. | |
| 289 | + | injectQUICClientFrame(t, client, Data{Physical: 1, Data: chunk}) | |
| 288 | 290 | injectQUICClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")}) | |
| 289 | 291 | ||
| 290 | 292 | taken, ok := takeEventually(t, pump, 2) | |
| @@ -294,14 +296,14 @@ func TestQUICCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||
| 294 | 296 | stalled := mustStatus(t, pump.Engine(), 1) | |
| 295 | 297 | require.Equal(t, StreamTerminal, stalled.State) | |
| 296 | 298 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | |
| 297 | - | require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind) | |
| 299 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind) | |
| 298 | 300 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | |
| 299 | 301 | require.False(t, pump.Engine().Closed()) | |
| 300 | 302 | require.False(t, channelClosed(pump.Done())) | |
| 301 | 303 | ||
| 302 | 304 | reset := decodeReset(t, nextQUICServerFrame(t, client), DirectionServer) | |
| 303 | 305 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | |
| 304 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | |
| 306 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, reset.Error.FailureKind) | |
| 305 | 307 | } | |
| 306 | 308 | ||
| 307 | 309 | // TestQUICCarriageResetIsolation proves, over a real QUIC carriage, that one | |
| @@ -273,18 +273,20 @@ func TestQUICCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||||
| 273 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | 273 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | ||
| 274 | } | 274 | } | ||
| 275 | 275 | ||||
| 276 | - | // The consumer never Takes stream 1: its bounded queue fills. | 276 | + | // A peer ignores flow control and fills stream 1's whole window while the |
| 277 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | 277 | + | // consumer never Takes it. |
| 278 | - | injectQUICClientFrame(t, client, Data{Physical: 1, Data: []byte{byte(i)}}) | 278 | + | chunk, full := floodWindow(pump.Ceilings()) |
| 279 | + | for i := 0; i < full; i++ { | |||
| 280 | + | injectQUICClientFrame(t, client, Data{Physical: 1, Data: chunk}) | |||
| 279 | } | 281 | } | ||
| 280 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | 282 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | ||
| 281 | status, ok := e.Status(1) | 283 | status, ok := e.Status(1) | ||
| 282 | - | return ok && status.QueuedChunks == MaxMuxStreamQueueChunks | 284 | + | return ok && status.QueuedChunks == full |
| 283 | }) | 285 | }) | ||
| 284 | 286 | ||||
| 285 | - | // One more chunk overflows stream 1's own bound; a sibling chunk still | 287 | + | // One more chunk exceeds the granted window; a sibling chunk still lands, |
| 286 | - | // lands, so neither the reader nor the sibling was blocked. | 288 | + | // so neither the reader nor the sibling was blocked. |
| 287 | - | injectQUICClientFrame(t, client, Data{Physical: 1, Data: []byte{'x'}}) | 289 | + | injectQUICClientFrame(t, client, Data{Physical: 1, Data: chunk}) |
| 288 | injectQUICClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")}) | 290 | injectQUICClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")}) | ||
| 289 | 291 | ||||
| 290 | taken, ok := takeEventually(t, pump, 2) | 292 | taken, ok := takeEventually(t, pump, 2) | ||
| @@ -294,14 +296,14 @@ func TestQUICCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||||
| 294 | stalled := mustStatus(t, pump.Engine(), 1) | 296 | stalled := mustStatus(t, pump.Engine(), 1) | ||
| 295 | require.Equal(t, StreamTerminal, stalled.State) | 297 | require.Equal(t, StreamTerminal, stalled.State) | ||
| 296 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | 298 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | ||
| 297 | - | require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind) | 299 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind) |
| 298 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | 300 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | ||
| 299 | require.False(t, pump.Engine().Closed()) | 301 | require.False(t, pump.Engine().Closed()) | ||
| 300 | require.False(t, channelClosed(pump.Done())) | 302 | require.False(t, channelClosed(pump.Done())) | ||
| 301 | 303 | ||||
| 302 | reset := decodeReset(t, nextQUICServerFrame(t, client), DirectionServer) | 304 | reset := decodeReset(t, nextQUICServerFrame(t, client), DirectionServer) | ||
| 303 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | 305 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | ||
| 304 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | 306 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, reset.Error.FailureKind) |
| 305 | } | 307 | } | ||
| 306 | 308 | ||||
| 307 | // TestQUICCarriageResetIsolation proves, over a real QUIC carriage, that one | 309 | // TestQUICCarriageResetIsolation proves, over a real QUIC carriage, that one | ||
M internal/adapters/daemonmux/scheduler.go +30 -22
| @@ -19,13 +19,14 @@ | |||
| 19 | 19 | // a refused Open is still answered; that refusal seals its own record. A | |
| 20 | 20 | // duplicate terminal, a duplicate refusal, and data for a settled, unadmitted, | |
| 21 | 21 | // or closing identity stay refused while the settling record is retained (see | |
| 22 | - | // below). Each stream queues at most MaxMuxStreamQueueChunks (8) envelopes and | |
| 23 | - | // MaxMuxStreamQueueBytes (32 MiB), and all streams together queue at most | |
| 24 | - | // MaxMuxAggregateBytes (64 MiB) of application data. A frame is charged | |
| 25 | - | // against both byte budgets before it is inserted, and a frame that would | |
| 26 | - | // exceed the per-stream frame bound, the per-stream byte bound, or the | |
| 27 | - | // aggregate byte bound discards exactly its own stream queue, frees that | |
| 28 | - | // budget, and leaves every sibling untouched. | |
| 22 | + | // below). Data frames are admitted only after the writer spent the stream's | |
| 23 | + | // send credit (see StreamEngine), so each stream's queued data never exceeds | |
| 24 | + | // its window and a slow peer slows the writer rather than filling the queue. | |
| 25 | + | // All streams together queue at most the negotiated aggregate bytes; a frame | |
| 26 | + | // that would exceed it discards exactly its own stream queue, frees that | |
| 27 | + | // budget, and leaves every sibling untouched. With the default ceilings the | |
| 28 | + | // stream windows fit inside the aggregate, so that bound is a safety net, not | |
| 29 | + | // a flow-control path. | |
| 29 | 30 | // | |
| 30 | 31 | // An overflowed stream is not sealed outright: the discard settles its data | |
| 31 | 32 | // but leaves its record open for exactly one terminal Close or Reset, so the | |
| @@ -126,9 +127,8 @@ var ( | |||
| 126 | 127 | // stream fencing. | |
| 127 | 128 | ErrSchedulerSettled = errors.New("daemonmux: stream is settled for scheduling") | |
| 128 | 129 | // ErrSchedulerFull reports an outbound frame that would exceed the | |
| 129 | - | // per-stream frame bound, the per-stream byte bound, or the aggregate byte | |
| 130 | - | // bound. The offending stream's queue is discarded and its budget | |
| 131 | - | // released. It also reports a non-terminal frame for a stream that already | |
| 130 | + | // aggregate byte bound. The offending stream's queue is discarded and its | |
| 131 | + | // budget released. It also reports a non-terminal frame for a stream that already | |
| 132 | 132 | // overflowed and is awaiting its one terminal Close or Reset: the data was | |
| 133 | 133 | // discarded and the record is not yet sealed. | |
| 134 | 134 | ErrSchedulerFull = errors.New("daemonmux: scheduler queue overflow") | |
| @@ -253,8 +253,6 @@ type Scheduler struct { | |||
| 253 | 253 | // Bounds and quantum are constructed from the package defaults and are | |
| 254 | 254 | // owned by the scheduler; they are fields so a test can tighten them. | |
| 255 | 255 | maxAggregateBytes int | |
| 256 | - | maxStreamBytes int | |
| 257 | - | maxStreamFrames int | |
| 258 | 256 | quantum int64 | |
| 259 | 257 | } | |
| 260 | 258 | ||
| @@ -280,8 +278,6 @@ func newScheduler(engine *StreamEngine, ceilings MuxCeilings) *Scheduler { | |||
| 280 | 278 | maxEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes, | |
| 281 | 279 | maxChunkBytes: ceilings.StreamChunkLimit, | |
| 282 | 280 | maxAggregateBytes: int(ceilings.MaxAggregateBytes), | |
| 283 | - | maxStreamBytes: MaxMuxStreamQueueBytes, | |
| 284 | - | maxStreamFrames: MaxMuxStreamQueueChunks, | |
| 285 | 281 | quantum: SchedulerQuantum, | |
| 286 | 282 | controlBurst: MaxMuxSchedulerControlBurst, | |
| 287 | 283 | } | |
| @@ -354,7 +350,7 @@ func (s *Scheduler) Refuse(message Refused) error { | |||
| 354 | 350 | stream = &schedStream{physical: physical} | |
| 355 | 351 | s.streams[physical] = stream | |
| 356 | 352 | } | |
| 357 | - | if s.overflowLocked(stream, len(frame.bytes)) { | |
| 353 | + | if s.overflowLocked(len(frame.bytes)) { | |
| 358 | 354 | s.overflowLockedStream(stream) | |
| 359 | 355 | return ErrSchedulerFull | |
| 360 | 356 | } | |
| @@ -436,7 +432,7 @@ func (s *Scheduler) enqueue(frame schedFrame, physical PhysicalStreamID) error { | |||
| 436 | 432 | stream = &schedStream{physical: physical} | |
| 437 | 433 | s.streams[physical] = stream | |
| 438 | 434 | } | |
| 439 | - | if s.overflowLocked(stream, len(frame.bytes)) { | |
| 435 | + | if s.overflowLocked(len(frame.bytes)) { | |
| 440 | 436 | s.overflowLockedStream(stream) | |
| 441 | 437 | return ErrSchedulerFull | |
| 442 | 438 | } | |
| @@ -651,12 +647,10 @@ func (s *Scheduler) listRetiredLocked(stream *schedStream) { | |||
| 651 | 647 | } | |
| 652 | 648 | } | |
| 653 | 649 | ||
| 654 | - | // overflowLocked reports whether one frame would exceed the per-stream frame | |
| 655 | - | // bound, the per-stream byte bound, or the aggregate byte bound. | |
| 656 | - | func (s *Scheduler) overflowLocked(stream *schedStream, n int) bool { | |
| 657 | - | return len(stream.queue) >= s.maxStreamFrames || | |
| 658 | - | stream.bytes+n > s.maxStreamBytes || | |
| 659 | - | s.aggregate+n > s.maxAggregateBytes | |
| 650 | + | // overflowLocked reports whether one frame would exceed the aggregate byte | |
| 651 | + | // bound. | |
| 652 | + | func (s *Scheduler) overflowLocked(n int) bool { | |
| 653 | + | return s.aggregate+n > s.maxAggregateBytes | |
| 660 | 654 | } | |
| 661 | 655 | ||
| 662 | 656 | // discardLocked drops every queued frame of one stream and returns its bytes | |
| @@ -891,6 +885,13 @@ func clientFrame(message ClientMessage) (PhysicalStreamID, FrameClass, error) { | |||
| 891 | 885 | return 0, 0, ErrInvalidMessage | |
| 892 | 886 | } | |
| 893 | 887 | return clientFrame(*m) | |
| 888 | + | case WindowUpdate: | |
| 889 | + | return m.Physical, FrameControl, nil | |
| 890 | + | case *WindowUpdate: | |
| 891 | + | if m == nil { | |
| 892 | + | return 0, 0, ErrInvalidMessage | |
| 893 | + | } | |
| 894 | + | return clientFrame(*m) | |
| 894 | 895 | default: | |
| 895 | 896 | return 0, 0, ErrWrongDirection | |
| 896 | 897 | } | |
| @@ -935,6 +936,13 @@ func serverFrame(message ServerMessage) (PhysicalStreamID, FrameClass, error) { | |||
| 935 | 936 | return 0, 0, ErrInvalidMessage | |
| 936 | 937 | } | |
| 937 | 938 | return serverFrame(*m) | |
| 939 | + | case WindowUpdate: | |
| 940 | + | return m.Physical, FrameControl, nil | |
| 941 | + | case *WindowUpdate: | |
| 942 | + | if m == nil { | |
| 943 | + | return 0, 0, ErrInvalidMessage | |
| 944 | + | } | |
| 945 | + | return serverFrame(*m) | |
| 938 | 946 | default: | |
| 939 | 947 | return 0, 0, ErrWrongDirection | |
| 940 | 948 | } | |
| @@ -19,13 +19,14 @@ | |||||
| 19 | // a refused Open is still answered; that refusal seals its own record. A | 19 | // a refused Open is still answered; that refusal seals its own record. A | ||
| 20 | // duplicate terminal, a duplicate refusal, and data for a settled, unadmitted, | 20 | // duplicate terminal, a duplicate refusal, and data for a settled, unadmitted, | ||
| 21 | // or closing identity stay refused while the settling record is retained (see | 21 | // or closing identity stay refused while the settling record is retained (see | ||
| 22 | - | // below). Each stream queues at most MaxMuxStreamQueueChunks (8) envelopes and | 22 | + | // below). Data frames are admitted only after the writer spent the stream's |
| 23 | - | // MaxMuxStreamQueueBytes (32 MiB), and all streams together queue at most | 23 | + | // send credit (see StreamEngine), so each stream's queued data never exceeds |
| 24 | - | // MaxMuxAggregateBytes (64 MiB) of application data. A frame is charged | 24 | + | // its window and a slow peer slows the writer rather than filling the queue. |
| 25 | - | // against both byte budgets before it is inserted, and a frame that would | 25 | + | // All streams together queue at most the negotiated aggregate bytes; a frame |
| 26 | - | // exceed the per-stream frame bound, the per-stream byte bound, or the | 26 | + | // that would exceed it discards exactly its own stream queue, frees that |
| 27 | - | // aggregate byte bound discards exactly its own stream queue, frees that | 27 | + | // budget, and leaves every sibling untouched. With the default ceilings the |
| 28 | - | // budget, and leaves every sibling untouched. | 28 | + | // stream windows fit inside the aggregate, so that bound is a safety net, not |
| 29 | + | // a flow-control path. | |||
| 29 | // | 30 | // | ||
| 30 | // An overflowed stream is not sealed outright: the discard settles its data | 31 | // An overflowed stream is not sealed outright: the discard settles its data | ||
| 31 | // but leaves its record open for exactly one terminal Close or Reset, so the | 32 | // but leaves its record open for exactly one terminal Close or Reset, so the | ||
| @@ -126,9 +127,8 @@ var ( | |||||
| 126 | // stream fencing. | 127 | // stream fencing. | ||
| 127 | ErrSchedulerSettled = errors.New("daemonmux: stream is settled for scheduling") | 128 | ErrSchedulerSettled = errors.New("daemonmux: stream is settled for scheduling") | ||
| 128 | // ErrSchedulerFull reports an outbound frame that would exceed the | 129 | // ErrSchedulerFull reports an outbound frame that would exceed the | ||
| 129 | - | // per-stream frame bound, the per-stream byte bound, or the aggregate byte | 130 | + | // aggregate byte bound. The offending stream's queue is discarded and its |
| 130 | - | // bound. The offending stream's queue is discarded and its budget | 131 | + | // budget released. It also reports a non-terminal frame for a stream that already |
| 131 | - | // released. It also reports a non-terminal frame for a stream that already | |||
| 132 | // overflowed and is awaiting its one terminal Close or Reset: the data was | 132 | // overflowed and is awaiting its one terminal Close or Reset: the data was | ||
| 133 | // discarded and the record is not yet sealed. | 133 | // discarded and the record is not yet sealed. | ||
| 134 | ErrSchedulerFull = errors.New("daemonmux: scheduler queue overflow") | 134 | ErrSchedulerFull = errors.New("daemonmux: scheduler queue overflow") | ||
| @@ -253,8 +253,6 @@ type Scheduler struct { | |||||
| 253 | // Bounds and quantum are constructed from the package defaults and are | 253 | // Bounds and quantum are constructed from the package defaults and are | ||
| 254 | // owned by the scheduler; they are fields so a test can tighten them. | 254 | // owned by the scheduler; they are fields so a test can tighten them. | ||
| 255 | maxAggregateBytes int | 255 | maxAggregateBytes int | ||
| 256 | - | maxStreamBytes int | |||
| 257 | - | maxStreamFrames int | |||
| 258 | quantum int64 | 256 | quantum int64 | ||
| 259 | } | 257 | } | ||
| 260 | 258 | ||||
| @@ -280,8 +278,6 @@ func newScheduler(engine *StreamEngine, ceilings MuxCeilings) *Scheduler { | |||||
| 280 | maxEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes, | 278 | maxEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes, | ||
| 281 | maxChunkBytes: ceilings.StreamChunkLimit, | 279 | maxChunkBytes: ceilings.StreamChunkLimit, | ||
| 282 | maxAggregateBytes: int(ceilings.MaxAggregateBytes), | 280 | maxAggregateBytes: int(ceilings.MaxAggregateBytes), | ||
| 283 | - | maxStreamBytes: MaxMuxStreamQueueBytes, | |||
| 284 | - | maxStreamFrames: MaxMuxStreamQueueChunks, | |||
| 285 | quantum: SchedulerQuantum, | 281 | quantum: SchedulerQuantum, | ||
| 286 | controlBurst: MaxMuxSchedulerControlBurst, | 282 | controlBurst: MaxMuxSchedulerControlBurst, | ||
| 287 | } | 283 | } | ||
| @@ -354,7 +350,7 @@ func (s *Scheduler) Refuse(message Refused) error { | |||||
| 354 | stream = &schedStream{physical: physical} | 350 | stream = &schedStream{physical: physical} | ||
| 355 | s.streams[physical] = stream | 351 | s.streams[physical] = stream | ||
| 356 | } | 352 | } | ||
| 357 | - | if s.overflowLocked(stream, len(frame.bytes)) { | 353 | + | if s.overflowLocked(len(frame.bytes)) { |
| 358 | s.overflowLockedStream(stream) | 354 | s.overflowLockedStream(stream) | ||
| 359 | return ErrSchedulerFull | 355 | return ErrSchedulerFull | ||
| 360 | } | 356 | } | ||
| @@ -436,7 +432,7 @@ func (s *Scheduler) enqueue(frame schedFrame, physical PhysicalStreamID) error { | |||||
| 436 | stream = &schedStream{physical: physical} | 432 | stream = &schedStream{physical: physical} | ||
| 437 | s.streams[physical] = stream | 433 | s.streams[physical] = stream | ||
| 438 | } | 434 | } | ||
| 439 | - | if s.overflowLocked(stream, len(frame.bytes)) { | 435 | + | if s.overflowLocked(len(frame.bytes)) { |
| 440 | s.overflowLockedStream(stream) | 436 | s.overflowLockedStream(stream) | ||
| 441 | return ErrSchedulerFull | 437 | return ErrSchedulerFull | ||
| 442 | } | 438 | } | ||
| @@ -651,12 +647,10 @@ func (s *Scheduler) listRetiredLocked(stream *schedStream) { | |||||
| 651 | } | 647 | } | ||
| 652 | } | 648 | } | ||
| 653 | 649 | ||||
| 654 | - | // overflowLocked reports whether one frame would exceed the per-stream frame | 650 | + | // overflowLocked reports whether one frame would exceed the aggregate byte |
| 655 | - | // bound, the per-stream byte bound, or the aggregate byte bound. | 651 | + | // bound. |
| 656 | - | func (s *Scheduler) overflowLocked(stream *schedStream, n int) bool { | 652 | + | func (s *Scheduler) overflowLocked(n int) bool { |
| 657 | - | return len(stream.queue) >= s.maxStreamFrames || | 653 | + | return s.aggregate+n > s.maxAggregateBytes |
| 658 | - | stream.bytes+n > s.maxStreamBytes || | |||
| 659 | - | s.aggregate+n > s.maxAggregateBytes | |||
| 660 | } | 654 | } | ||
| 661 | 655 | ||||
| 662 | // discardLocked drops every queued frame of one stream and returns its bytes | 656 | // discardLocked drops every queued frame of one stream and returns its bytes | ||
| @@ -891,6 +885,13 @@ func clientFrame(message ClientMessage) (PhysicalStreamID, FrameClass, error) { | |||||
| 891 | return 0, 0, ErrInvalidMessage | 885 | return 0, 0, ErrInvalidMessage | ||
| 892 | } | 886 | } | ||
| 893 | return clientFrame(*m) | 887 | return clientFrame(*m) | ||
| 888 | + | case WindowUpdate: | |||
| 889 | + | return m.Physical, FrameControl, nil | |||
| 890 | + | case *WindowUpdate: | |||
| 891 | + | if m == nil { | |||
| 892 | + | return 0, 0, ErrInvalidMessage | |||
| 893 | + | } | |||
| 894 | + | return clientFrame(*m) | |||
| 894 | default: | 895 | default: | ||
| 895 | return 0, 0, ErrWrongDirection | 896 | return 0, 0, ErrWrongDirection | ||
| 896 | } | 897 | } | ||
| @@ -935,6 +936,13 @@ func serverFrame(message ServerMessage) (PhysicalStreamID, FrameClass, error) { | |||||
| 935 | return 0, 0, ErrInvalidMessage | 936 | return 0, 0, ErrInvalidMessage | ||
| 936 | } | 937 | } | ||
| 937 | return serverFrame(*m) | 938 | return serverFrame(*m) | ||
| 939 | + | case WindowUpdate: | |||
| 940 | + | return m.Physical, FrameControl, nil | |||
| 941 | + | case *WindowUpdate: | |||
| 942 | + | if m == nil { | |||
| 943 | + | return 0, 0, ErrInvalidMessage | |||
| 944 | + | } | |||
| 945 | + | return serverFrame(*m) | |||
| 938 | default: | 946 | default: | ||
| 939 | return 0, 0, ErrWrongDirection | 947 | return 0, 0, ErrWrongDirection | ||
| 940 | } | 948 | } | ||
M internal/adapters/daemonmux/scheduler_test.go +24 -55
| @@ -36,12 +36,23 @@ func countActive(scheduler *Scheduler, ids []PhysicalStreamID) int { | |||
| 36 | 36 | return active | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | + | // overflowOne queues one small frame for id and then overflows the aggregate | |
| 40 | + | // bound with a frame the budget cannot hold, so id's record is overflowed and | |
| 41 | + | // empty. The aggregate is restored for the next caller. | |
| 42 | + | func overflowOne(t *testing.T, scheduler *Scheduler, id PhysicalStreamID) { | |
| 43 | + | t.Helper() | |
| 44 | + | saved := scheduler.maxAggregateBytes | |
| 45 | + | defer func() { scheduler.maxAggregateBytes = saved }() | |
| 46 | + | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")})) | |
| 47 | + | scheduler.maxAggregateBytes = scheduler.AggregateBytes() | |
| 48 | + | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("b")}), ErrSchedulerFull) | |
| 49 | + | } | |
| 50 | + | ||
| 39 | 51 | // TestSchedulerConstants pins the negotiated bounds the scheduler is specified | |
| 40 | 52 | // to enforce. | |
| 41 | 53 | func TestSchedulerConstants(t *testing.T) { | |
| 42 | - | require.Equal(t, 8, MaxMuxStreamQueueChunks) | |
| 43 | - | require.Equal(t, 32<<20, MaxMuxStreamQueueBytes) | |
| 44 | 54 | require.Equal(t, 64<<20, int(MaxMuxAggregateBytes)) | |
| 55 | + | require.Equal(t, uint64(512<<10), DefaultMuxCeilings().StreamWindow()) | |
| 45 | 56 | require.Equal(t, int64(64<<10), SchedulerQuantum) | |
| 46 | 57 | require.Equal(t, 4, MaxMuxSchedulerControlBurst) | |
| 47 | 58 | } | |
| @@ -180,8 +191,9 @@ func TestSchedulerStalledAndHotStream(t *testing.T) { | |||
| 180 | 191 | ||
| 181 | 192 | // The hot stream keeps a maximal backlog; the siblings are refilled to full | |
| 182 | 193 | // whenever the round drains a slot. | |
| 194 | + | const backlog = 8 | |
| 183 | 195 | fill := func(id PhysicalStreamID, seed byte) { | |
| 184 | - | for scheduler.QueuedFrames(id) < MaxMuxStreamQueueChunks { | |
| 196 | + | for scheduler.QueuedFrames(id) < backlog { | |
| 185 | 197 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte{seed}})) | |
| 186 | 198 | } | |
| 187 | 199 | } | |
| @@ -315,45 +327,9 @@ func TestSchedulerResetDiscard(t *testing.T) { | |||
| 315 | 327 | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: 3, Data: []byte("x")}), ErrSchedulerSettled) | |
| 316 | 328 | } | |
| 317 | 329 | ||
| 318 | - | // TestSchedulerAggregateAndPerStreamCaps proves each bound discards exactly the | |
| 330 | + | // TestSchedulerAggregateCap proves the aggregate bound discards exactly the | |
| 319 | 331 | // offending stream and leaves its siblings' budget intact. | |
| 320 | - | func TestSchedulerAggregateAndPerStreamCaps(t *testing.T) { | |
| 321 | - | t.Run("per-stream frame bound", func(t *testing.T) { | |
| 322 | - | _, scheduler, ids := testScheduler(t, 2) | |
| 323 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | |
| 324 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: []byte{byte(i)}})) | |
| 325 | - | } | |
| 326 | - | require.Equal(t, MaxMuxStreamQueueChunks, scheduler.QueuedFrames(ids[0])) | |
| 327 | - | ||
| 328 | - | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: []byte("x")}), ErrSchedulerFull) | |
| 329 | - | require.Zero(t, scheduler.QueuedFrames(ids[0])) | |
| 330 | - | require.Zero(t, scheduler.QueuedBytes(ids[0])) | |
| 331 | - | // The overflowed record is not yet sealed: data stays refused with the | |
| 332 | - | // overflow sentinel until its one terminal Close or Reset is enqueued. | |
| 333 | - | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: []byte("y")}), ErrSchedulerFull) | |
| 334 | - | ||
| 335 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[1], Data: []byte("sibling")})) | |
| 336 | - | require.Equal(t, scheduler.QueuedBytes(ids[1]), scheduler.AggregateBytes()) | |
| 337 | - | }) | |
| 338 | - | ||
| 339 | - | t.Run("per-stream byte bound", func(t *testing.T) { | |
| 340 | - | _, scheduler, ids := testScheduler(t, 1) | |
| 341 | - | payload := make([]byte, 300) | |
| 342 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: payload})) | |
| 343 | - | unit := scheduler.QueuedBytes(ids[0]) | |
| 344 | - | require.Positive(t, unit) | |
| 345 | - | ||
| 346 | - | scheduler.maxStreamFrames = 100 | |
| 347 | - | scheduler.maxStreamBytes = unit * 3 | |
| 348 | - | for i := 0; i < 2; i++ { | |
| 349 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: payload})) | |
| 350 | - | } | |
| 351 | - | require.Equal(t, unit*3, scheduler.QueuedBytes(ids[0])) | |
| 352 | - | ||
| 353 | - | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: payload}), ErrSchedulerFull) | |
| 354 | - | require.Zero(t, scheduler.AggregateBytes()) | |
| 355 | - | }) | |
| 356 | - | ||
| 332 | + | func TestSchedulerAggregateCap(t *testing.T) { | |
| 357 | 333 | t.Run("aggregate byte bound", func(t *testing.T) { | |
| 358 | 334 | _, scheduler, ids := testScheduler(t, 3) | |
| 359 | 335 | payload := make([]byte, 300) | |
| @@ -361,8 +337,6 @@ func TestSchedulerAggregateAndPerStreamCaps(t *testing.T) { | |||
| 361 | 337 | unit := scheduler.QueuedBytes(ids[0]) | |
| 362 | 338 | require.Positive(t, unit) | |
| 363 | 339 | ||
| 364 | - | scheduler.maxStreamFrames = 100 | |
| 365 | - | scheduler.maxStreamBytes = unit * 100 | |
| 366 | 340 | scheduler.maxAggregateBytes = unit * 3 | |
| 367 | 341 | ||
| 368 | 342 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: payload})) | |
| @@ -561,8 +535,6 @@ func TestNewSchedulerWithCeilings(t *testing.T) { | |||
| 561 | 535 | // the offending stream and frees its budget. | |
| 562 | 536 | scheduler, err := NewSchedulerWithCeilings(nil, probeCeiling) | |
| 563 | 537 | require.NoError(t, err) | |
| 564 | - | scheduler.maxStreamFrames = 100 | |
| 565 | - | scheduler.maxStreamBytes = unit * 100 | |
| 566 | 538 | scheduler.maxAggregateBytes = unit * 2 | |
| 567 | 539 | // The negotiated chunk ceiling is refused statelessly. | |
| 568 | 540 | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: 1, Data: make([]byte, 9)}), ErrTooLarge) | |
| @@ -610,11 +582,12 @@ func TestSchedulerTerminalBypass(t *testing.T) { | |||
| 610 | 582 | engine := testEngine() | |
| 611 | 583 | scheduler := NewScheduler(engine) | |
| 612 | 584 | openTestStream(t, engine, 1) | |
| 613 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | |
| 614 | - | _, err := engine.Data(Data{Physical: 1, Data: []byte{byte(i)}}) | |
| 585 | + | chunk, full := floodWindow(DefaultMuxCeilings()) | |
| 586 | + | for i := 0; i < full; i++ { | |
| 587 | + | _, err := engine.Data(Data{Physical: 1, Data: chunk}) | |
| 615 | 588 | require.NoError(t, err) | |
| 616 | 589 | } | |
| 617 | - | _, err := engine.Data(Data{Physical: 1, Data: []byte{'x'}}) | |
| 590 | + | _, err := engine.Data(Data{Physical: 1, Data: chunk}) | |
| 618 | 591 | require.ErrorIs(t, err, ErrStreamQueueFull) | |
| 619 | 592 | require.Equal(t, StreamTerminal, mustStatus(t, engine, 1).State) | |
| 620 | 593 | ||
| @@ -746,8 +719,8 @@ func TestSchedulerOverflowAllowsOneTerminal(t *testing.T) { | |||
| 746 | 719 | t.Run(terminal.name, func(t *testing.T) { | |
| 747 | 720 | _, scheduler, ids := testScheduler(t, 1) | |
| 748 | 721 | id := ids[0] | |
| 749 | - | scheduler.maxStreamFrames = 2 | |
| 750 | 722 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")})) | |
| 723 | + | scheduler.maxAggregateBytes = 2 * scheduler.AggregateBytes() | |
| 751 | 724 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("b")})) | |
| 752 | 725 | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("c")}), ErrSchedulerFull) | |
| 753 | 726 | require.Zero(t, scheduler.QueuedFrames(id)) | |
| @@ -779,12 +752,10 @@ func TestSchedulerOverflowAllowsOneTerminal(t *testing.T) { | |||
| 779 | 752 | // MaxRetiredSchedulerRecords. | |
| 780 | 753 | func TestSchedulerOverflowRecordsBounded(t *testing.T) { | |
| 781 | 754 | scheduler := NewScheduler(nil) | |
| 782 | - | scheduler.maxStreamFrames = 1 | |
| 783 | 755 | const churn = MaxRetiredSchedulerRecords * 4 | |
| 784 | 756 | for i := 1; i <= churn; i++ { | |
| 785 | 757 | id := PhysicalStreamID(i) | |
| 786 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")})) | |
| 787 | - | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("b")}), ErrSchedulerFull) | |
| 758 | + | overflowOne(t, scheduler, id) | |
| 788 | 759 | } | |
| 789 | 760 | require.LessOrEqual(t, len(scheduler.streams), MaxRetiredSchedulerRecords) | |
| 790 | 761 | require.Zero(t, scheduler.AggregateBytes()) | |
| @@ -799,14 +770,12 @@ func TestSchedulerOverflowRecordsBounded(t *testing.T) { | |||
| 799 | 770 | // stream table to the configured bound. | |
| 800 | 771 | func TestSchedulerOverflowTerminalReclaimsRetiredRecords(t *testing.T) { | |
| 801 | 772 | scheduler := NewScheduler(nil) | |
| 802 | - | scheduler.maxStreamFrames = 1 | |
| 803 | 773 | const churn = MaxRetiredSchedulerRecords + 64 | |
| 804 | 774 | ||
| 805 | 775 | for i := 1; i <= churn; i++ { | |
| 806 | 776 | id := PhysicalStreamID(i) | |
| 807 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")})) | |
| 808 | 777 | // Overflow the queue so the record is retired while still empty. | |
| 809 | - | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("b")}), ErrSchedulerFull) | |
| 778 | + | overflowOne(t, scheduler, id) | |
| 810 | 779 | // The one terminal the overflow leaves open lands while the record sits | |
| 811 | 780 | // in the retired list; later streams force it past the bound. | |
| 812 | 781 | require.NoError(t, scheduler.EnqueueClient(Reset{Physical: id})) | |
| @@ -36,12 +36,23 @@ func countActive(scheduler *Scheduler, ids []PhysicalStreamID) int { | |||||
| 36 | return active | 36 | return active | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | + | // overflowOne queues one small frame for id and then overflows the aggregate | |||
| 40 | + | // bound with a frame the budget cannot hold, so id's record is overflowed and | |||
| 41 | + | // empty. The aggregate is restored for the next caller. | |||
| 42 | + | func overflowOne(t *testing.T, scheduler *Scheduler, id PhysicalStreamID) { | |||
| 43 | + | t.Helper() | |||
| 44 | + | saved := scheduler.maxAggregateBytes | |||
| 45 | + | defer func() { scheduler.maxAggregateBytes = saved }() | |||
| 46 | + | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")})) | |||
| 47 | + | scheduler.maxAggregateBytes = scheduler.AggregateBytes() | |||
| 48 | + | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("b")}), ErrSchedulerFull) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 39 | // TestSchedulerConstants pins the negotiated bounds the scheduler is specified | 51 | // TestSchedulerConstants pins the negotiated bounds the scheduler is specified | ||
| 40 | // to enforce. | 52 | // to enforce. | ||
| 41 | func TestSchedulerConstants(t *testing.T) { | 53 | func TestSchedulerConstants(t *testing.T) { | ||
| 42 | - | require.Equal(t, 8, MaxMuxStreamQueueChunks) | |||
| 43 | - | require.Equal(t, 32<<20, MaxMuxStreamQueueBytes) | |||
| 44 | require.Equal(t, 64<<20, int(MaxMuxAggregateBytes)) | 54 | require.Equal(t, 64<<20, int(MaxMuxAggregateBytes)) | ||
| 55 | + | require.Equal(t, uint64(512<<10), DefaultMuxCeilings().StreamWindow()) | |||
| 45 | require.Equal(t, int64(64<<10), SchedulerQuantum) | 56 | require.Equal(t, int64(64<<10), SchedulerQuantum) | ||
| 46 | require.Equal(t, 4, MaxMuxSchedulerControlBurst) | 57 | require.Equal(t, 4, MaxMuxSchedulerControlBurst) | ||
| 47 | } | 58 | } | ||
| @@ -180,8 +191,9 @@ func TestSchedulerStalledAndHotStream(t *testing.T) { | |||||
| 180 | 191 | ||||
| 181 | // The hot stream keeps a maximal backlog; the siblings are refilled to full | 192 | // The hot stream keeps a maximal backlog; the siblings are refilled to full | ||
| 182 | // whenever the round drains a slot. | 193 | // whenever the round drains a slot. | ||
| 194 | + | const backlog = 8 | |||
| 183 | fill := func(id PhysicalStreamID, seed byte) { | 195 | fill := func(id PhysicalStreamID, seed byte) { | ||
| 184 | - | for scheduler.QueuedFrames(id) < MaxMuxStreamQueueChunks { | 196 | + | for scheduler.QueuedFrames(id) < backlog { |
| 185 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte{seed}})) | 197 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte{seed}})) | ||
| 186 | } | 198 | } | ||
| 187 | } | 199 | } | ||
| @@ -315,45 +327,9 @@ func TestSchedulerResetDiscard(t *testing.T) { | |||||
| 315 | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: 3, Data: []byte("x")}), ErrSchedulerSettled) | 327 | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: 3, Data: []byte("x")}), ErrSchedulerSettled) | ||
| 316 | } | 328 | } | ||
| 317 | 329 | ||||
| 318 | - | // TestSchedulerAggregateAndPerStreamCaps proves each bound discards exactly the | 330 | + | // TestSchedulerAggregateCap proves the aggregate bound discards exactly the |
| 319 | // offending stream and leaves its siblings' budget intact. | 331 | // offending stream and leaves its siblings' budget intact. | ||
| 320 | - | func TestSchedulerAggregateAndPerStreamCaps(t *testing.T) { | 332 | + | func TestSchedulerAggregateCap(t *testing.T) { |
| 321 | - | t.Run("per-stream frame bound", func(t *testing.T) { | |||
| 322 | - | _, scheduler, ids := testScheduler(t, 2) | |||
| 323 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | |||
| 324 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: []byte{byte(i)}})) | |||
| 325 | - | } | |||
| 326 | - | require.Equal(t, MaxMuxStreamQueueChunks, scheduler.QueuedFrames(ids[0])) | |||
| 327 | - | ||||
| 328 | - | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: []byte("x")}), ErrSchedulerFull) | |||
| 329 | - | require.Zero(t, scheduler.QueuedFrames(ids[0])) | |||
| 330 | - | require.Zero(t, scheduler.QueuedBytes(ids[0])) | |||
| 331 | - | // The overflowed record is not yet sealed: data stays refused with the | |||
| 332 | - | // overflow sentinel until its one terminal Close or Reset is enqueued. | |||
| 333 | - | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: []byte("y")}), ErrSchedulerFull) | |||
| 334 | - | ||||
| 335 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[1], Data: []byte("sibling")})) | |||
| 336 | - | require.Equal(t, scheduler.QueuedBytes(ids[1]), scheduler.AggregateBytes()) | |||
| 337 | - | }) | |||
| 338 | - | ||||
| 339 | - | t.Run("per-stream byte bound", func(t *testing.T) { | |||
| 340 | - | _, scheduler, ids := testScheduler(t, 1) | |||
| 341 | - | payload := make([]byte, 300) | |||
| 342 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: payload})) | |||
| 343 | - | unit := scheduler.QueuedBytes(ids[0]) | |||
| 344 | - | require.Positive(t, unit) | |||
| 345 | - | ||||
| 346 | - | scheduler.maxStreamFrames = 100 | |||
| 347 | - | scheduler.maxStreamBytes = unit * 3 | |||
| 348 | - | for i := 0; i < 2; i++ { | |||
| 349 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: payload})) | |||
| 350 | - | } | |||
| 351 | - | require.Equal(t, unit*3, scheduler.QueuedBytes(ids[0])) | |||
| 352 | - | ||||
| 353 | - | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: payload}), ErrSchedulerFull) | |||
| 354 | - | require.Zero(t, scheduler.AggregateBytes()) | |||
| 355 | - | }) | |||
| 356 | - | ||||
| 357 | t.Run("aggregate byte bound", func(t *testing.T) { | 333 | t.Run("aggregate byte bound", func(t *testing.T) { | ||
| 358 | _, scheduler, ids := testScheduler(t, 3) | 334 | _, scheduler, ids := testScheduler(t, 3) | ||
| 359 | payload := make([]byte, 300) | 335 | payload := make([]byte, 300) | ||
| @@ -361,8 +337,6 @@ func TestSchedulerAggregateAndPerStreamCaps(t *testing.T) { | |||||
| 361 | unit := scheduler.QueuedBytes(ids[0]) | 337 | unit := scheduler.QueuedBytes(ids[0]) | ||
| 362 | require.Positive(t, unit) | 338 | require.Positive(t, unit) | ||
| 363 | 339 | ||||
| 364 | - | scheduler.maxStreamFrames = 100 | |||
| 365 | - | scheduler.maxStreamBytes = unit * 100 | |||
| 366 | scheduler.maxAggregateBytes = unit * 3 | 340 | scheduler.maxAggregateBytes = unit * 3 | ||
| 367 | 341 | ||||
| 368 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: payload})) | 342 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: payload})) | ||
| @@ -561,8 +535,6 @@ func TestNewSchedulerWithCeilings(t *testing.T) { | |||||
| 561 | // the offending stream and frees its budget. | 535 | // the offending stream and frees its budget. | ||
| 562 | scheduler, err := NewSchedulerWithCeilings(nil, probeCeiling) | 536 | scheduler, err := NewSchedulerWithCeilings(nil, probeCeiling) | ||
| 563 | require.NoError(t, err) | 537 | require.NoError(t, err) | ||
| 564 | - | scheduler.maxStreamFrames = 100 | |||
| 565 | - | scheduler.maxStreamBytes = unit * 100 | |||
| 566 | scheduler.maxAggregateBytes = unit * 2 | 538 | scheduler.maxAggregateBytes = unit * 2 | ||
| 567 | // The negotiated chunk ceiling is refused statelessly. | 539 | // The negotiated chunk ceiling is refused statelessly. | ||
| 568 | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: 1, Data: make([]byte, 9)}), ErrTooLarge) | 540 | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: 1, Data: make([]byte, 9)}), ErrTooLarge) | ||
| @@ -610,11 +582,12 @@ func TestSchedulerTerminalBypass(t *testing.T) { | |||||
| 610 | engine := testEngine() | 582 | engine := testEngine() | ||
| 611 | scheduler := NewScheduler(engine) | 583 | scheduler := NewScheduler(engine) | ||
| 612 | openTestStream(t, engine, 1) | 584 | openTestStream(t, engine, 1) | ||
| 613 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | 585 | + | chunk, full := floodWindow(DefaultMuxCeilings()) |
| 614 | - | _, err := engine.Data(Data{Physical: 1, Data: []byte{byte(i)}}) | 586 | + | for i := 0; i < full; i++ { |
| 587 | + | _, err := engine.Data(Data{Physical: 1, Data: chunk}) | |||
| 615 | require.NoError(t, err) | 588 | require.NoError(t, err) | ||
| 616 | } | 589 | } | ||
| 617 | - | _, err := engine.Data(Data{Physical: 1, Data: []byte{'x'}}) | 590 | + | _, err := engine.Data(Data{Physical: 1, Data: chunk}) |
| 618 | require.ErrorIs(t, err, ErrStreamQueueFull) | 591 | require.ErrorIs(t, err, ErrStreamQueueFull) | ||
| 619 | require.Equal(t, StreamTerminal, mustStatus(t, engine, 1).State) | 592 | require.Equal(t, StreamTerminal, mustStatus(t, engine, 1).State) | ||
| 620 | 593 | ||||
| @@ -746,8 +719,8 @@ func TestSchedulerOverflowAllowsOneTerminal(t *testing.T) { | |||||
| 746 | t.Run(terminal.name, func(t *testing.T) { | 719 | t.Run(terminal.name, func(t *testing.T) { | ||
| 747 | _, scheduler, ids := testScheduler(t, 1) | 720 | _, scheduler, ids := testScheduler(t, 1) | ||
| 748 | id := ids[0] | 721 | id := ids[0] | ||
| 749 | - | scheduler.maxStreamFrames = 2 | |||
| 750 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")})) | 722 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")})) | ||
| 723 | + | scheduler.maxAggregateBytes = 2 * scheduler.AggregateBytes() | |||
| 751 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("b")})) | 724 | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("b")})) | ||
| 752 | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("c")}), ErrSchedulerFull) | 725 | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("c")}), ErrSchedulerFull) | ||
| 753 | require.Zero(t, scheduler.QueuedFrames(id)) | 726 | require.Zero(t, scheduler.QueuedFrames(id)) | ||
| @@ -779,12 +752,10 @@ func TestSchedulerOverflowAllowsOneTerminal(t *testing.T) { | |||||
| 779 | // MaxRetiredSchedulerRecords. | 752 | // MaxRetiredSchedulerRecords. | ||
| 780 | func TestSchedulerOverflowRecordsBounded(t *testing.T) { | 753 | func TestSchedulerOverflowRecordsBounded(t *testing.T) { | ||
| 781 | scheduler := NewScheduler(nil) | 754 | scheduler := NewScheduler(nil) | ||
| 782 | - | scheduler.maxStreamFrames = 1 | |||
| 783 | const churn = MaxRetiredSchedulerRecords * 4 | 755 | const churn = MaxRetiredSchedulerRecords * 4 | ||
| 784 | for i := 1; i <= churn; i++ { | 756 | for i := 1; i <= churn; i++ { | ||
| 785 | id := PhysicalStreamID(i) | 757 | id := PhysicalStreamID(i) | ||
| 786 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")})) | 758 | + | overflowOne(t, scheduler, id) |
| 787 | - | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("b")}), ErrSchedulerFull) | |||
| 788 | } | 759 | } | ||
| 789 | require.LessOrEqual(t, len(scheduler.streams), MaxRetiredSchedulerRecords) | 760 | require.LessOrEqual(t, len(scheduler.streams), MaxRetiredSchedulerRecords) | ||
| 790 | require.Zero(t, scheduler.AggregateBytes()) | 761 | require.Zero(t, scheduler.AggregateBytes()) | ||
| @@ -799,14 +770,12 @@ func TestSchedulerOverflowRecordsBounded(t *testing.T) { | |||||
| 799 | // stream table to the configured bound. | 770 | // stream table to the configured bound. | ||
| 800 | func TestSchedulerOverflowTerminalReclaimsRetiredRecords(t *testing.T) { | 771 | func TestSchedulerOverflowTerminalReclaimsRetiredRecords(t *testing.T) { | ||
| 801 | scheduler := NewScheduler(nil) | 772 | scheduler := NewScheduler(nil) | ||
| 802 | - | scheduler.maxStreamFrames = 1 | |||
| 803 | const churn = MaxRetiredSchedulerRecords + 64 | 773 | const churn = MaxRetiredSchedulerRecords + 64 | ||
| 804 | 774 | ||||
| 805 | for i := 1; i <= churn; i++ { | 775 | for i := 1; i <= churn; i++ { | ||
| 806 | id := PhysicalStreamID(i) | 776 | id := PhysicalStreamID(i) | ||
| 807 | - | require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")})) | |||
| 808 | // Overflow the queue so the record is retired while still empty. | 777 | // Overflow the queue so the record is retired while still empty. | ||
| 809 | - | require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("b")}), ErrSchedulerFull) | 778 | + | overflowOne(t, scheduler, id) |
| 810 | // The one terminal the overflow leaves open lands while the record sits | 779 | // The one terminal the overflow leaves open lands while the record sits | ||
| 811 | // in the retired list; later streams force it past the bound. | 780 | // in the retired list; later streams force it past the bound. | ||
| 812 | require.NoError(t, scheduler.EnqueueClient(Reset{Physical: id})) | 781 | require.NoError(t, scheduler.EnqueueClient(Reset{Physical: id})) | ||
M internal/adapters/daemonmux/ssh_carriage_test.go +12 -10
| @@ -491,7 +491,7 @@ func TestSSHCarriageConnectErrorBoundedAndSanitized(t *testing.T) { | |||
| 491 | 491 | // in-process relay end. Every byte crosses the real subprocess pipes. | |
| 492 | 492 | func sshRawCarriagePair(t *testing.T) (RawFramedTransport, RawFramedTransport) { | |
| 493 | 493 | t.Helper() | |
| 494 | - | sockPath := filepath.Join(t.TempDir(), "relay.sock") | |
| 494 | + | sockPath := filepath.Join(filepath.Dir(muxCarriagePath(t)), "relay.sock") | |
| 495 | 495 | listener, err := net.Listen("unix", sockPath) | |
| 496 | 496 | require.NoError(t, err) | |
| 497 | 497 | t.Cleanup(func() { _ = listener.Close() }) | |
| @@ -571,18 +571,20 @@ func TestSSHCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||
| 571 | 571 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | |
| 572 | 572 | } | |
| 573 | 573 | ||
| 574 | - | // The consumer never Takes stream 1: its bounded queue fills. | |
| 575 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | |
| 576 | - | injectSSHClientFrame(t, client, Data{Physical: 1, Data: []byte{byte(i)}}) | |
| 574 | + | // A peer ignores flow control and fills stream 1's whole window while the | |
| 575 | + | // consumer never Takes it. | |
| 576 | + | chunk, full := floodWindow(pump.Ceilings()) | |
| 577 | + | for i := 0; i < full; i++ { | |
| 578 | + | injectSSHClientFrame(t, client, Data{Physical: 1, Data: chunk}) | |
| 577 | 579 | } | |
| 578 | 580 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | |
| 579 | 581 | status, ok := e.Status(1) | |
| 580 | - | return ok && status.QueuedChunks == MaxMuxStreamQueueChunks | |
| 582 | + | return ok && status.QueuedChunks == full | |
| 581 | 583 | }) | |
| 582 | 584 | ||
| 583 | - | // One more chunk overflows stream 1's own bound; a sibling chunk still | |
| 584 | - | // lands, so neither the reader nor the sibling was blocked. | |
| 585 | - | injectSSHClientFrame(t, client, Data{Physical: 1, Data: []byte{'x'}}) | |
| 585 | + | // One more chunk exceeds the granted window; a sibling chunk still lands, | |
| 586 | + | // so neither the reader nor the sibling was blocked. | |
| 587 | + | injectSSHClientFrame(t, client, Data{Physical: 1, Data: chunk}) | |
| 586 | 588 | injectSSHClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")}) | |
| 587 | 589 | ||
| 588 | 590 | taken, ok := takeEventually(t, pump, 2) | |
| @@ -592,14 +594,14 @@ func TestSSHCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||
| 592 | 594 | stalled := mustStatus(t, pump.Engine(), 1) | |
| 593 | 595 | require.Equal(t, StreamTerminal, stalled.State) | |
| 594 | 596 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | |
| 595 | - | require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind) | |
| 597 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind) | |
| 596 | 598 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | |
| 597 | 599 | require.False(t, pump.Engine().Closed()) | |
| 598 | 600 | require.False(t, channelClosed(pump.Done())) | |
| 599 | 601 | ||
| 600 | 602 | reset := decodeReset(t, nextSSHServerFrame(t, client), DirectionServer) | |
| 601 | 603 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | |
| 602 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | |
| 604 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, reset.Error.FailureKind) | |
| 603 | 605 | } | |
| 604 | 606 | ||
| 605 | 607 | // TestSSHCarriageResetIsolation proves, over a real ssh carriage, that one | |
| @@ -491,7 +491,7 @@ func TestSSHCarriageConnectErrorBoundedAndSanitized(t *testing.T) { | |||||
| 491 | // in-process relay end. Every byte crosses the real subprocess pipes. | 491 | // in-process relay end. Every byte crosses the real subprocess pipes. | ||
| 492 | func sshRawCarriagePair(t *testing.T) (RawFramedTransport, RawFramedTransport) { | 492 | func sshRawCarriagePair(t *testing.T) (RawFramedTransport, RawFramedTransport) { | ||
| 493 | t.Helper() | 493 | t.Helper() | ||
| 494 | - | sockPath := filepath.Join(t.TempDir(), "relay.sock") | 494 | + | sockPath := filepath.Join(filepath.Dir(muxCarriagePath(t)), "relay.sock") |
| 495 | listener, err := net.Listen("unix", sockPath) | 495 | listener, err := net.Listen("unix", sockPath) | ||
| 496 | require.NoError(t, err) | 496 | require.NoError(t, err) | ||
| 497 | t.Cleanup(func() { _ = listener.Close() }) | 497 | t.Cleanup(func() { _ = listener.Close() }) | ||
| @@ -571,18 +571,20 @@ func TestSSHCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||||
| 571 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | 571 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | ||
| 572 | } | 572 | } | ||
| 573 | 573 | ||||
| 574 | - | // The consumer never Takes stream 1: its bounded queue fills. | 574 | + | // A peer ignores flow control and fills stream 1's whole window while the |
| 575 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | 575 | + | // consumer never Takes it. |
| 576 | - | injectSSHClientFrame(t, client, Data{Physical: 1, Data: []byte{byte(i)}}) | 576 | + | chunk, full := floodWindow(pump.Ceilings()) |
| 577 | + | for i := 0; i < full; i++ { | |||
| 578 | + | injectSSHClientFrame(t, client, Data{Physical: 1, Data: chunk}) | |||
| 577 | } | 579 | } | ||
| 578 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | 580 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | ||
| 579 | status, ok := e.Status(1) | 581 | status, ok := e.Status(1) | ||
| 580 | - | return ok && status.QueuedChunks == MaxMuxStreamQueueChunks | 582 | + | return ok && status.QueuedChunks == full |
| 581 | }) | 583 | }) | ||
| 582 | 584 | ||||
| 583 | - | // One more chunk overflows stream 1's own bound; a sibling chunk still | 585 | + | // One more chunk exceeds the granted window; a sibling chunk still lands, |
| 584 | - | // lands, so neither the reader nor the sibling was blocked. | 586 | + | // so neither the reader nor the sibling was blocked. |
| 585 | - | injectSSHClientFrame(t, client, Data{Physical: 1, Data: []byte{'x'}}) | 587 | + | injectSSHClientFrame(t, client, Data{Physical: 1, Data: chunk}) |
| 586 | injectSSHClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")}) | 588 | injectSSHClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")}) | ||
| 587 | 589 | ||||
| 588 | taken, ok := takeEventually(t, pump, 2) | 590 | taken, ok := takeEventually(t, pump, 2) | ||
| @@ -592,14 +594,14 @@ func TestSSHCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||||
| 592 | stalled := mustStatus(t, pump.Engine(), 1) | 594 | stalled := mustStatus(t, pump.Engine(), 1) | ||
| 593 | require.Equal(t, StreamTerminal, stalled.State) | 595 | require.Equal(t, StreamTerminal, stalled.State) | ||
| 594 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | 596 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | ||
| 595 | - | require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind) | 597 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind) |
| 596 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | 598 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | ||
| 597 | require.False(t, pump.Engine().Closed()) | 599 | require.False(t, pump.Engine().Closed()) | ||
| 598 | require.False(t, channelClosed(pump.Done())) | 600 | require.False(t, channelClosed(pump.Done())) | ||
| 599 | 601 | ||||
| 600 | reset := decodeReset(t, nextSSHServerFrame(t, client), DirectionServer) | 602 | reset := decodeReset(t, nextSSHServerFrame(t, client), DirectionServer) | ||
| 601 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | 603 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | ||
| 602 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | 604 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, reset.Error.FailureKind) |
| 603 | } | 605 | } | ||
| 604 | 606 | ||||
| 605 | // TestSSHCarriageResetIsolation proves, over a real ssh carriage, that one | 607 | // TestSSHCarriageResetIsolation proves, over a real ssh carriage, that one | ||
M internal/adapters/daemonmux/unix_carriage_test.go +11 -9
| @@ -256,18 +256,20 @@ func TestUnixCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||
| 256 | 256 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | |
| 257 | 257 | } | |
| 258 | 258 | ||
| 259 | - | // The consumer never Takes stream 1: its bounded queue fills. | |
| 260 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | |
| 261 | - | injectUnixClientFrame(t, client, Data{Physical: 1, Data: []byte{byte(i)}}) | |
| 259 | + | // A peer ignores flow control and fills stream 1's whole window while the | |
| 260 | + | // consumer never Takes it. | |
| 261 | + | chunk, full := floodWindow(pump.Ceilings()) | |
| 262 | + | for i := 0; i < full; i++ { | |
| 263 | + | injectUnixClientFrame(t, client, Data{Physical: 1, Data: chunk}) | |
| 262 | 264 | } | |
| 263 | 265 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | |
| 264 | 266 | status, ok := e.Status(1) | |
| 265 | - | return ok && status.QueuedChunks == MaxMuxStreamQueueChunks | |
| 267 | + | return ok && status.QueuedChunks == full | |
| 266 | 268 | }) | |
| 267 | 269 | ||
| 268 | - | // One more chunk overflows stream 1's own bound; a sibling chunk still | |
| 269 | - | // lands, so neither the reader nor the sibling was blocked. | |
| 270 | - | injectUnixClientFrame(t, client, Data{Physical: 1, Data: []byte{'x'}}) | |
| 270 | + | // One more chunk exceeds the granted window; a sibling chunk still lands, | |
| 271 | + | // so neither the reader nor the sibling was blocked. | |
| 272 | + | injectUnixClientFrame(t, client, Data{Physical: 1, Data: chunk}) | |
| 271 | 273 | injectUnixClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")}) | |
| 272 | 274 | ||
| 273 | 275 | taken, ok := takeEventually(t, pump, 2) | |
| @@ -277,14 +279,14 @@ func TestUnixCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||
| 277 | 279 | stalled := mustStatus(t, pump.Engine(), 1) | |
| 278 | 280 | require.Equal(t, StreamTerminal, stalled.State) | |
| 279 | 281 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | |
| 280 | - | require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind) | |
| 282 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind) | |
| 281 | 283 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | |
| 282 | 284 | require.False(t, pump.Engine().Closed()) | |
| 283 | 285 | require.False(t, channelClosed(pump.Done())) | |
| 284 | 286 | ||
| 285 | 287 | reset := decodeReset(t, nextUnixServerFrame(t, client), DirectionServer) | |
| 286 | 288 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | |
| 287 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | |
| 289 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, reset.Error.FailureKind) | |
| 288 | 290 | } | |
| 289 | 291 | ||
| 290 | 292 | // TestUnixCarriageResetIsolation proves, over a real Unix carriage, that one | |
| @@ -256,18 +256,20 @@ func TestUnixCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||||
| 256 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | 256 | require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))})) | ||
| 257 | } | 257 | } | ||
| 258 | 258 | ||||
| 259 | - | // The consumer never Takes stream 1: its bounded queue fills. | 259 | + | // A peer ignores flow control and fills stream 1's whole window while the |
| 260 | - | for i := 0; i < MaxMuxStreamQueueChunks; i++ { | 260 | + | // consumer never Takes it. |
| 261 | - | injectUnixClientFrame(t, client, Data{Physical: 1, Data: []byte{byte(i)}}) | 261 | + | chunk, full := floodWindow(pump.Ceilings()) |
| 262 | + | for i := 0; i < full; i++ { | |||
| 263 | + | injectUnixClientFrame(t, client, Data{Physical: 1, Data: chunk}) | |||
| 262 | } | 264 | } | ||
| 263 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | 265 | requireEngineEventually(t, pump, func(e *StreamEngine) bool { | ||
| 264 | status, ok := e.Status(1) | 266 | status, ok := e.Status(1) | ||
| 265 | - | return ok && status.QueuedChunks == MaxMuxStreamQueueChunks | 267 | + | return ok && status.QueuedChunks == full |
| 266 | }) | 268 | }) | ||
| 267 | 269 | ||||
| 268 | - | // One more chunk overflows stream 1's own bound; a sibling chunk still | 270 | + | // One more chunk exceeds the granted window; a sibling chunk still lands, |
| 269 | - | // lands, so neither the reader nor the sibling was blocked. | 271 | + | // so neither the reader nor the sibling was blocked. |
| 270 | - | injectUnixClientFrame(t, client, Data{Physical: 1, Data: []byte{'x'}}) | 272 | + | injectUnixClientFrame(t, client, Data{Physical: 1, Data: chunk}) |
| 271 | injectUnixClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")}) | 273 | injectUnixClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")}) | ||
| 272 | 274 | ||||
| 273 | taken, ok := takeEventually(t, pump, 2) | 275 | taken, ok := takeEventually(t, pump, 2) | ||
| @@ -277,14 +279,14 @@ func TestUnixCarriageBlockedConsumerSiblingProgress(t *testing.T) { | |||||
| 277 | stalled := mustStatus(t, pump.Engine(), 1) | 279 | stalled := mustStatus(t, pump.Engine(), 1) | ||
| 278 | require.Equal(t, StreamTerminal, stalled.State) | 280 | require.Equal(t, StreamTerminal, stalled.State) | ||
| 279 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | 281 | require.ErrorIs(t, stalled.Err, ErrStreamQueueFull) | ||
| 280 | - | require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind) | 282 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind) |
| 281 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | 283 | require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State) | ||
| 282 | require.False(t, pump.Engine().Closed()) | 284 | require.False(t, pump.Engine().Closed()) | ||
| 283 | require.False(t, channelClosed(pump.Done())) | 285 | require.False(t, channelClosed(pump.Done())) | ||
| 284 | 286 | ||||
| 285 | reset := decodeReset(t, nextUnixServerFrame(t, client), DirectionServer) | 287 | reset := decodeReset(t, nextUnixServerFrame(t, client), DirectionServer) | ||
| 286 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | 288 | require.Equal(t, PhysicalStreamID(1), reset.Physical) | ||
| 287 | - | require.Equal(t, domain.RemoteFailureTransport, reset.Error.FailureKind) | 289 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, reset.Error.FailureKind) |
| 288 | } | 290 | } | ||
| 289 | 291 | ||||
| 290 | // TestUnixCarriageResetIsolation proves, over a real Unix carriage, that one | 292 | // TestUnixCarriageResetIsolation proves, over a real Unix carriage, that one | ||
M internal/adapters/ipc/mux_unsupported_test.go +1 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | //go:build !linux | |
| 1 | + | //go:build !linux && !darwin | |
| 2 | 2 | ||
| 3 | 3 | package ipc | |
| 4 | 4 | ||
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | //go:build !linux | 1 | + | //go:build !linux && !darwin |
| 2 | 2 | ||||
| 3 | package ipc | 3 | package ipc | ||
| 4 | 4 | ||||
A internal/adapters/ipc/peer_darwin.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | //go:build darwin | |
| 2 | + | ||
| 3 | + | package ipc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "net" | |
| 8 | + | "os" | |
| 9 | + | ||
| 10 | + | "golang.org/x/sys/unix" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // verifySameUserUnixPeer returns nil only when the peer at the other end of an | |
| 14 | + | // AF_UNIX connection is the current effective user, as reported by the kernel | |
| 15 | + | // through LOCAL_PEERCRED. A socket whose credentials cannot be read is refused. | |
| 16 | + | func verifySameUserUnixPeer(conn *net.UnixConn) error { | |
| 17 | + | if conn == nil { | |
| 18 | + | return ErrMuxPeerRejected | |
| 19 | + | } | |
| 20 | + | raw, err := conn.SyscallConn() | |
| 21 | + | if err != nil { | |
| 22 | + | return fmt.Errorf("%w: %v", ErrMuxPeerRejected, err) | |
| 23 | + | } | |
| 24 | + | var credential *unix.Xucred | |
| 25 | + | var controlErr error | |
| 26 | + | if err := raw.Control(func(fd uintptr) { | |
| 27 | + | credential, controlErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) | |
| 28 | + | }); err != nil { | |
| 29 | + | return fmt.Errorf("%w: %v", ErrMuxPeerRejected, err) | |
| 30 | + | } | |
| 31 | + | if controlErr != nil { | |
| 32 | + | return fmt.Errorf("%w: %v", ErrMuxPeerRejected, controlErr) | |
| 33 | + | } | |
| 34 | + | if credential == nil { | |
| 35 | + | return ErrMuxPeerRejected | |
| 36 | + | } | |
| 37 | + | if uid := uint32(os.Geteuid()); uid != credential.Uid { | |
| 38 | + | return fmt.Errorf("%w: peer uid %d, want %d", ErrMuxPeerRejected, credential.Uid, uid) | |
| 39 | + | } | |
| 40 | + | return nil | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | //go:build darwin | |||
| 2 | + | ||||
| 3 | + | package ipc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "net" | |||
| 8 | + | "os" | |||
| 9 | + | ||||
| 10 | + | "golang.org/x/sys/unix" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // verifySameUserUnixPeer returns nil only when the peer at the other end of an | |||
| 14 | + | // AF_UNIX connection is the current effective user, as reported by the kernel | |||
| 15 | + | // through LOCAL_PEERCRED. A socket whose credentials cannot be read is refused. | |||
| 16 | + | func verifySameUserUnixPeer(conn *net.UnixConn) error { | |||
| 17 | + | if conn == nil { | |||
| 18 | + | return ErrMuxPeerRejected | |||
| 19 | + | } | |||
| 20 | + | raw, err := conn.SyscallConn() | |||
| 21 | + | if err != nil { | |||
| 22 | + | return fmt.Errorf("%w: %v", ErrMuxPeerRejected, err) | |||
| 23 | + | } | |||
| 24 | + | var credential *unix.Xucred | |||
| 25 | + | var controlErr error | |||
| 26 | + | if err := raw.Control(func(fd uintptr) { | |||
| 27 | + | credential, controlErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) | |||
| 28 | + | }); err != nil { | |||
| 29 | + | return fmt.Errorf("%w: %v", ErrMuxPeerRejected, err) | |||
| 30 | + | } | |||
| 31 | + | if controlErr != nil { | |||
| 32 | + | return fmt.Errorf("%w: %v", ErrMuxPeerRejected, controlErr) | |||
| 33 | + | } | |||
| 34 | + | if credential == nil { | |||
| 35 | + | return ErrMuxPeerRejected | |||
| 36 | + | } | |||
| 37 | + | if uid := uint32(os.Geteuid()); uid != credential.Uid { | |||
| 38 | + | return fmt.Errorf("%w: peer uid %d, want %d", ErrMuxPeerRejected, credential.Uid, uid) | |||
| 39 | + | } | |||
| 40 | + | return nil | |||
| 41 | + | } | |||
M internal/adapters/ipc/peer_other.go +3 -3
| @@ -1,11 +1,11 @@ | |||
| 1 | - | //go:build !linux | |
| 1 | + | //go:build !linux && !darwin | |
| 2 | 2 | ||
| 3 | 3 | package ipc | |
| 4 | 4 | ||
| 5 | 5 | import "net" | |
| 6 | 6 | ||
| 7 | - | // verifySameUserUnixPeer fails closed on a platform or build without a Linux | |
| 8 | - | // SO_PEERCRED same-user credential check wired into this adapter. The private | |
| 7 | + | // verifySameUserUnixPeer fails closed on a platform or build without a | |
| 8 | + | // same-user credential check wired into this adapter. The private | |
| 9 | 9 | // carriage never admits an unverified peer: a caller sees ErrMuxUnsupported | |
| 10 | 10 | // rather than a same-user result inferred from filesystem permissions or an | |
| 11 | 11 | // X11-style trust assumption. | |
| @@ -1,11 +1,11 @@ | |||||
| 1 | - | //go:build !linux | 1 | + | //go:build !linux && !darwin |
| 2 | 2 | ||||
| 3 | package ipc | 3 | package ipc | ||
| 4 | 4 | ||||
| 5 | import "net" | 5 | import "net" | ||
| 6 | 6 | ||||
| 7 | - | // verifySameUserUnixPeer fails closed on a platform or build without a Linux | 7 | + | // verifySameUserUnixPeer fails closed on a platform or build without a |
| 8 | - | // SO_PEERCRED same-user credential check wired into this adapter. The private | 8 | + | // same-user credential check wired into this adapter. The private |
| 9 | // carriage never admits an unverified peer: a caller sees ErrMuxUnsupported | 9 | // carriage never admits an unverified peer: a caller sees ErrMuxUnsupported | ||
| 10 | // rather than a same-user result inferred from filesystem permissions or an | 10 | // rather than a same-user result inferred from filesystem permissions or an | ||
| 11 | // X11-style trust assumption. | 11 | // X11-style trust assumption. | ||
M internal/app/broker_offline_status_subprocess_test.go +3 -2
| @@ -65,10 +65,11 @@ func recordBrokerHelperProcess(role string) { | |||
| 65 | 65 | // The AF_UNIX pathname limit is about 103 bytes, and t.TempDir embeds the full | |
| 66 | 66 | // test name; the broker socket lives several components beneath the sandbox | |
| 67 | 67 | // root, so a long test name would overflow the socket path. A short base keeps | |
| 68 | - | // the real subprocess tests honest. | |
| 68 | + | // the real subprocess tests honest. It uses /tmp because macOS TMPDIR is | |
| 69 | + | // already a long /var/folders path. | |
| 69 | 70 | func shortTempDir(t *testing.T, prefix string) string { | |
| 70 | 71 | t.Helper() | |
| 71 | - | dir, err := os.MkdirTemp("", prefix) | |
| 72 | + | dir, err := os.MkdirTemp("/tmp", prefix) | |
| 72 | 73 | require.NoError(t, err) | |
| 73 | 74 | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | |
| 74 | 75 | return dir | |
| @@ -65,10 +65,11 @@ func recordBrokerHelperProcess(role string) { | |||||
| 65 | // The AF_UNIX pathname limit is about 103 bytes, and t.TempDir embeds the full | 65 | // The AF_UNIX pathname limit is about 103 bytes, and t.TempDir embeds the full | ||
| 66 | // test name; the broker socket lives several components beneath the sandbox | 66 | // test name; the broker socket lives several components beneath the sandbox | ||
| 67 | // root, so a long test name would overflow the socket path. A short base keeps | 67 | // root, so a long test name would overflow the socket path. A short base keeps | ||
| 68 | - | // the real subprocess tests honest. | 68 | + | // the real subprocess tests honest. It uses /tmp because macOS TMPDIR is |
| 69 | + | // already a long /var/folders path. | |||
| 69 | func shortTempDir(t *testing.T, prefix string) string { | 70 | func shortTempDir(t *testing.T, prefix string) string { | ||
| 70 | t.Helper() | 71 | t.Helper() | ||
| 71 | - | dir, err := os.MkdirTemp("", prefix) | 72 | + | dir, err := os.MkdirTemp("/tmp", prefix) |
| 72 | require.NoError(t, err) | 73 | require.NoError(t, err) | ||
| 73 | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | 74 | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | ||
| 74 | return dir | 75 | return dir | ||
M internal/app/broker_offline_test.go +1 -1
| @@ -457,7 +457,7 @@ type brokerMuxFixture struct { | |||
| 457 | 457 | // Pings on every admitted logical stream. | |
| 458 | 458 | func newBrokerMuxFixture(t *testing.T, policy ports.BrokerPolicy) *brokerMuxFixture { | |
| 459 | 459 | t.Helper() | |
| 460 | - | routeDir := t.TempDir() | |
| 460 | + | routeDir := shortTempDir(t, "mux") | |
| 461 | 461 | require.NoError(t, os.Chmod(routeDir, 0o700)) | |
| 462 | 462 | return newBrokerMuxFixtureAt(t, policy, filepath.Join(routeDir, "mux.sock")) | |
| 463 | 463 | } | |
| @@ -457,7 +457,7 @@ type brokerMuxFixture struct { | |||||
| 457 | // Pings on every admitted logical stream. | 457 | // Pings on every admitted logical stream. | ||
| 458 | func newBrokerMuxFixture(t *testing.T, policy ports.BrokerPolicy) *brokerMuxFixture { | 458 | func newBrokerMuxFixture(t *testing.T, policy ports.BrokerPolicy) *brokerMuxFixture { | ||
| 459 | t.Helper() | 459 | t.Helper() | ||
| 460 | - | routeDir := t.TempDir() | 460 | + | routeDir := shortTempDir(t, "mux") |
| 461 | require.NoError(t, os.Chmod(routeDir, 0o700)) | 461 | require.NoError(t, os.Chmod(routeDir, 0o700)) | ||
| 462 | return newBrokerMuxFixtureAt(t, policy, filepath.Join(routeDir, "mux.sock")) | 462 | return newBrokerMuxFixtureAt(t, policy, filepath.Join(routeDir, "mux.sock")) | ||
| 463 | } | 463 | } | ||
M internal/app/broker_ready_test.go +1 -1
| @@ -886,7 +886,7 @@ func TestBrokerReadyEndpointSecurityClassificationTable(t *testing.T) { | |||
| 886 | 886 | regular := filepath.Join(dir, "not-a-socket") | |
| 887 | 887 | require.NoError(t, os.WriteFile(regular, []byte("x"), 0o600)) | |
| 888 | 888 | ||
| 889 | - | socketDir := t.TempDir() | |
| 889 | + | socketDir := shortTempDir(t, "ready") | |
| 890 | 890 | socketPath := filepath.Join(socketDir, "broker.sock") | |
| 891 | 891 | listener, err := net.Listen("unix", socketPath) | |
| 892 | 892 | require.NoError(t, err) | |
| @@ -886,7 +886,7 @@ func TestBrokerReadyEndpointSecurityClassificationTable(t *testing.T) { | |||||
| 886 | regular := filepath.Join(dir, "not-a-socket") | 886 | regular := filepath.Join(dir, "not-a-socket") | ||
| 887 | require.NoError(t, os.WriteFile(regular, []byte("x"), 0o600)) | 887 | require.NoError(t, os.WriteFile(regular, []byte("x"), 0o600)) | ||
| 888 | 888 | ||||
| 889 | - | socketDir := t.TempDir() | 889 | + | socketDir := shortTempDir(t, "ready") |
| 890 | socketPath := filepath.Join(socketDir, "broker.sock") | 890 | socketPath := filepath.Join(socketDir, "broker.sock") | ||
| 891 | listener, err := net.Listen("unix", socketPath) | 891 | listener, err := net.Listen("unix", socketPath) | ||
| 892 | require.NoError(t, err) | 892 | require.NoError(t, err) | ||
M internal/ports/broker_acquisition_test.go +1 -1
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | func acquisitionTestPolicy() BrokerPolicy { | |
| 12 | - | return BrokerPolicy{ProtocolVersion: 59, CatalogSchemaVersion: 3, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: "quic", Trust: "openssh", Launch: "explicit", Isolation: "user"} | |
| 12 | + | return BrokerPolicy{ProtocolVersion: 60, CatalogSchemaVersion: 3, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: "quic", Trust: "openssh", Launch: "explicit", Isolation: "user"} | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | 15 | func TestBrokerDialTargetFirstContactAndBoundValidation(t *testing.T) { | |
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | func acquisitionTestPolicy() BrokerPolicy { | 11 | func acquisitionTestPolicy() BrokerPolicy { | ||
| 12 | - | return BrokerPolicy{ProtocolVersion: 59, CatalogSchemaVersion: 3, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: "quic", Trust: "openssh", Launch: "explicit", Isolation: "user"} | 12 | + | return BrokerPolicy{ProtocolVersion: 60, CatalogSchemaVersion: 3, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: "quic", Trust: "openssh", Launch: "explicit", Isolation: "user"} |
| 13 | } | 13 | } | ||
| 14 | 14 | ||||
| 15 | func TestBrokerDialTargetFirstContactAndBoundValidation(t *testing.T) { | 15 | func TestBrokerDialTargetFirstContactAndBoundValidation(t *testing.T) { | ||
M internal/protocol/session.go +1 -1
| @@ -16,7 +16,7 @@ import ( | |||
| 16 | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | |
| 17 | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | |
| 18 | 18 | // version as the session and broker conversations. | |
| 19 | - | const Version uint16 = 59 | |
| 19 | + | const Version uint16 = 60 | |
| 20 | 20 | ||
| 21 | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 22 | 22 | // first committed publication. It excludes the preceding client-local | |
| @@ -16,7 +16,7 @@ import ( | |||||
| 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | ||
| 17 | // negotiated ceilings and accepted daemon binding ride the same wire | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | ||
| 18 | // version as the session and broker conversations. | 18 | // version as the session and broker conversations. | ||
| 19 | - | const Version uint16 = 59 | 19 | + | const Version uint16 = 60 |
| 20 | 20 | ||||
| 21 | // HandshakeTimeout bounds every transport handshake from connect through the | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 22 | // first committed publication. It excludes the preceding client-local | 22 | // first committed publication. It excludes the preceding client-local | ||
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(59), Version) | |
| 13 | + | require.Equal(t, uint16(60), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | ||
| 13 | - | require.Equal(t, uint16(59), Version) | 13 | + | require.Equal(t, uint16(60), Version) |
| 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
M internal/protocol/wire/envelope_inventory_test.go +2 -2
| @@ -15,8 +15,8 @@ import ( | |||
| 15 | 15 | // shares a payload type except route navigation failure, and the preamble | |
| 16 | 16 | // constants agree with the schema authority. | |
| 17 | 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |
| 18 | - | if protocol.Version != 59 { | |
| 19 | - | t.Fatalf("protocol.Version = %d, want 59", protocol.Version) | |
| 18 | + | if protocol.Version != 60 { | |
| 19 | + | t.Fatalf("protocol.Version = %d, want 60", protocol.Version) | |
| 20 | 20 | } | |
| 21 | 21 | if ProtocolEpoch != 1 { | |
| 22 | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | |
| @@ -15,8 +15,8 @@ import ( | |||||
| 15 | // shares a payload type except route navigation failure, and the preamble | 15 | // shares a payload type except route navigation failure, and the preamble | ||
| 16 | // constants agree with the schema authority. | 16 | // constants agree with the schema authority. | ||
| 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | ||
| 18 | - | if protocol.Version != 59 { | 18 | + | if protocol.Version != 60 { |
| 19 | - | t.Fatalf("protocol.Version = %d, want 59", protocol.Version) | 19 | + | t.Fatalf("protocol.Version = %d, want 60", protocol.Version) |
| 20 | } | 20 | } | ||
| 21 | if ProtocolEpoch != 1 { | 21 | if ProtocolEpoch != 1 { | ||
| 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | ||
M internal/protocol/wire/multiplex.pb.go +153 -52 generated file: collapsed
M internal/protocol/wire/multiplex_test.go +12 -10
| @@ -17,12 +17,12 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // muxClientTags is the frozen client tag inventory. | |
| 19 | 19 | var muxClientTags = map[string]protoreflect.FieldNumber{ | |
| 20 | - | "open": 301, "data": 302, "close": 303, "reset": 304, | |
| 20 | + | "open": 301, "data": 302, "close": 303, "reset": 304, "window_update": 305, | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | // muxServerTags is the frozen server tag inventory. | |
| 24 | 24 | var muxServerTags = map[string]protoreflect.FieldNumber{ | |
| 25 | - | "opened": 401, "refused": 402, "data": 403, "close": 404, "reset": 405, | |
| 25 | + | "opened": 401, "refused": 402, "data": 403, "close": 404, "reset": 405, "window_update": 406, | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | func muxStreamRef() *MuxStreamRef { | |
| @@ -59,20 +59,22 @@ func muxClientSamples() map[string]*MuxClientEnvelope { | |||
| 59 | 59 | Env: []string{"TERM=xterm-256color", "LANG=C.UTF-8"}, | |
| 60 | 60 | Policy: muxPolicy(), | |
| 61 | 61 | }}}, | |
| 62 | - | "data": {Payload: &MuxClientEnvelope_Data{Data: &MuxData{PhysicalStreamId: 5, Data: bytes.Repeat([]byte{0x66}, 24)}}}, | |
| 63 | - | "close": {Payload: &MuxClientEnvelope_Close{Close: &MuxClose{PhysicalStreamId: 5}}}, | |
| 64 | - | "reset": {Payload: &MuxClientEnvelope_Reset_{Reset_: &MuxReset{PhysicalStreamId: 5, Error: muxErrorDetail()}}}, | |
| 62 | + | "data": {Payload: &MuxClientEnvelope_Data{Data: &MuxData{PhysicalStreamId: 5, Data: bytes.Repeat([]byte{0x66}, 24)}}}, | |
| 63 | + | "close": {Payload: &MuxClientEnvelope_Close{Close: &MuxClose{PhysicalStreamId: 5}}}, | |
| 64 | + | "reset": {Payload: &MuxClientEnvelope_Reset_{Reset_: &MuxReset{PhysicalStreamId: 5, Error: muxErrorDetail()}}}, | |
| 65 | + | "window_update": {Payload: &MuxClientEnvelope_WindowUpdate{WindowUpdate: &MuxWindowUpdate{PhysicalStreamId: 5, CreditBytes: 4096}}}, | |
| 65 | 66 | } | |
| 66 | 67 | } | |
| 67 | 68 | ||
| 68 | 69 | // muxServerSamples returns one populated envelope per server variant. | |
| 69 | 70 | func muxServerSamples() map[string]*MuxServerEnvelope { | |
| 70 | 71 | return map[string]*MuxServerEnvelope{ | |
| 71 | - | "opened": {Payload: &MuxServerEnvelope_Opened{Opened: &MuxOpened{Ref: muxStreamRef()}}}, | |
| 72 | - | "refused": {Payload: &MuxServerEnvelope_Refused{Refused: &MuxRefused{Ref: muxStreamRef(), Error: muxErrorDetail()}}}, | |
| 73 | - | "data": {Payload: &MuxServerEnvelope_Data{Data: &MuxData{PhysicalStreamId: 5, Data: bytes.Repeat([]byte{0x77}, 24)}}}, | |
| 74 | - | "close": {Payload: &MuxServerEnvelope_Close{Close: &MuxClose{PhysicalStreamId: 5}}}, | |
| 75 | - | "reset": {Payload: &MuxServerEnvelope_Reset_{Reset_: &MuxReset{PhysicalStreamId: 5}}}, | |
| 72 | + | "opened": {Payload: &MuxServerEnvelope_Opened{Opened: &MuxOpened{Ref: muxStreamRef()}}}, | |
| 73 | + | "refused": {Payload: &MuxServerEnvelope_Refused{Refused: &MuxRefused{Ref: muxStreamRef(), Error: muxErrorDetail()}}}, | |
| 74 | + | "data": {Payload: &MuxServerEnvelope_Data{Data: &MuxData{PhysicalStreamId: 5, Data: bytes.Repeat([]byte{0x77}, 24)}}}, | |
| 75 | + | "close": {Payload: &MuxServerEnvelope_Close{Close: &MuxClose{PhysicalStreamId: 5}}}, | |
| 76 | + | "reset": {Payload: &MuxServerEnvelope_Reset_{Reset_: &MuxReset{PhysicalStreamId: 5}}}, | |
| 77 | + | "window_update": {Payload: &MuxServerEnvelope_WindowUpdate{WindowUpdate: &MuxWindowUpdate{PhysicalStreamId: 5, CreditBytes: 4096}}}, | |
| 76 | 78 | } | |
| 77 | 79 | } | |
| 78 | 80 | ||
| @@ -17,12 +17,12 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // muxClientTags is the frozen client tag inventory. | 18 | // muxClientTags is the frozen client tag inventory. | ||
| 19 | var muxClientTags = map[string]protoreflect.FieldNumber{ | 19 | var muxClientTags = map[string]protoreflect.FieldNumber{ | ||
| 20 | - | "open": 301, "data": 302, "close": 303, "reset": 304, | 20 | + | "open": 301, "data": 302, "close": 303, "reset": 304, "window_update": 305, |
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | // muxServerTags is the frozen server tag inventory. | 23 | // muxServerTags is the frozen server tag inventory. | ||
| 24 | var muxServerTags = map[string]protoreflect.FieldNumber{ | 24 | var muxServerTags = map[string]protoreflect.FieldNumber{ | ||
| 25 | - | "opened": 401, "refused": 402, "data": 403, "close": 404, "reset": 405, | 25 | + | "opened": 401, "refused": 402, "data": 403, "close": 404, "reset": 405, "window_update": 406, |
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | func muxStreamRef() *MuxStreamRef { | 28 | func muxStreamRef() *MuxStreamRef { | ||
| @@ -59,20 +59,22 @@ func muxClientSamples() map[string]*MuxClientEnvelope { | |||||
| 59 | Env: []string{"TERM=xterm-256color", "LANG=C.UTF-8"}, | 59 | Env: []string{"TERM=xterm-256color", "LANG=C.UTF-8"}, | ||
| 60 | Policy: muxPolicy(), | 60 | Policy: muxPolicy(), | ||
| 61 | }}}, | 61 | }}}, | ||
| 62 | - | "data": {Payload: &MuxClientEnvelope_Data{Data: &MuxData{PhysicalStreamId: 5, Data: bytes.Repeat([]byte{0x66}, 24)}}}, | 62 | + | "data": {Payload: &MuxClientEnvelope_Data{Data: &MuxData{PhysicalStreamId: 5, Data: bytes.Repeat([]byte{0x66}, 24)}}}, |
| 63 | - | "close": {Payload: &MuxClientEnvelope_Close{Close: &MuxClose{PhysicalStreamId: 5}}}, | 63 | + | "close": {Payload: &MuxClientEnvelope_Close{Close: &MuxClose{PhysicalStreamId: 5}}}, |
| 64 | - | "reset": {Payload: &MuxClientEnvelope_Reset_{Reset_: &MuxReset{PhysicalStreamId: 5, Error: muxErrorDetail()}}}, | 64 | + | "reset": {Payload: &MuxClientEnvelope_Reset_{Reset_: &MuxReset{PhysicalStreamId: 5, Error: muxErrorDetail()}}}, |
| 65 | + | "window_update": {Payload: &MuxClientEnvelope_WindowUpdate{WindowUpdate: &MuxWindowUpdate{PhysicalStreamId: 5, CreditBytes: 4096}}}, | |||
| 65 | } | 66 | } | ||
| 66 | } | 67 | } | ||
| 67 | 68 | ||||
| 68 | // muxServerSamples returns one populated envelope per server variant. | 69 | // muxServerSamples returns one populated envelope per server variant. | ||
| 69 | func muxServerSamples() map[string]*MuxServerEnvelope { | 70 | func muxServerSamples() map[string]*MuxServerEnvelope { | ||
| 70 | return map[string]*MuxServerEnvelope{ | 71 | return map[string]*MuxServerEnvelope{ | ||
| 71 | - | "opened": {Payload: &MuxServerEnvelope_Opened{Opened: &MuxOpened{Ref: muxStreamRef()}}}, | 72 | + | "opened": {Payload: &MuxServerEnvelope_Opened{Opened: &MuxOpened{Ref: muxStreamRef()}}}, |
| 72 | - | "refused": {Payload: &MuxServerEnvelope_Refused{Refused: &MuxRefused{Ref: muxStreamRef(), Error: muxErrorDetail()}}}, | 73 | + | "refused": {Payload: &MuxServerEnvelope_Refused{Refused: &MuxRefused{Ref: muxStreamRef(), Error: muxErrorDetail()}}}, |
| 73 | - | "data": {Payload: &MuxServerEnvelope_Data{Data: &MuxData{PhysicalStreamId: 5, Data: bytes.Repeat([]byte{0x77}, 24)}}}, | 74 | + | "data": {Payload: &MuxServerEnvelope_Data{Data: &MuxData{PhysicalStreamId: 5, Data: bytes.Repeat([]byte{0x77}, 24)}}}, |
| 74 | - | "close": {Payload: &MuxServerEnvelope_Close{Close: &MuxClose{PhysicalStreamId: 5}}}, | 75 | + | "close": {Payload: &MuxServerEnvelope_Close{Close: &MuxClose{PhysicalStreamId: 5}}}, |
| 75 | - | "reset": {Payload: &MuxServerEnvelope_Reset_{Reset_: &MuxReset{PhysicalStreamId: 5}}}, | 76 | + | "reset": {Payload: &MuxServerEnvelope_Reset_{Reset_: &MuxReset{PhysicalStreamId: 5}}}, |
| 77 | + | "window_update": {Payload: &MuxServerEnvelope_WindowUpdate{WindowUpdate: &MuxWindowUpdate{PhysicalStreamId: 5, CreditBytes: 4096}}}, | |||
| 76 | } | 78 | } | ||
| 77 | } | 79 | } | ||
| 78 | 80 | ||||
M internal/protocol/wire/schema/multiplex.proto +16 -2
| @@ -4,13 +4,13 @@ | |||
| 4 | 4 | // | |
| 5 | 5 | // daemonmux is a third conversation, separate from the session protocol | |
| 6 | 6 | // (tags 1-32) and the broker protocol (client 101-111, server 201-209): | |
| 7 | - | // client tags 301-304 and server tags 401-405 are disjoint from every other | |
| 7 | + | // client tags 301-305 and server tags 401-406 are disjoint from every other | |
| 8 | 8 | // tag, and each direction is its own closed oneof union. The physical | |
| 9 | 9 | // preamble is a separate adapter message pair, not an envelope variant: | |
| 10 | 10 | // roles 5/6 negotiate the per-connection ceilings, the authenticated daemon | |
| 11 | 11 | // identity, and the accepted policy once, before any application message. | |
| 12 | 12 | // | |
| 13 | - | // MuxData, MuxClose, and MuxReset are shared payloads carried in both | |
| 13 | + | // MuxData, MuxClose, MuxReset, and MuxWindowUpdate are shared payloads carried in both | |
| 14 | 14 | // directions; MuxOpen carries the full stream identity, and every later | |
| 15 | 15 | // frame is routed by physical_stream_id alone. | |
| 16 | 16 | syntax = "proto3"; | |
| @@ -95,6 +95,18 @@ message MuxReset { | |||
| 95 | 95 | BrokerErrorDetail error = 2; | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | + | // MuxWindowUpdate returns receive credit for one logical stream in either | |
| 99 | + | // direction: the receiver consumed MuxData frames worth `credit_bytes` credit | |
| 100 | + | // bytes, so the sender may spend that many more. One MuxData frame costs its | |
| 101 | + | // payload length plus 64 credit bytes. Each stream starts with the same window | |
| 102 | + | // on both sides, derived from the negotiated ceilings; a sender never has more | |
| 103 | + | // credit in flight than its window, so a slow receiver slows the sender | |
| 104 | + | // instead of overflowing its queue. | |
| 105 | + | message MuxWindowUpdate { | |
| 106 | + | uint64 physical_stream_id = 1; | |
| 107 | + | uint64 credit_bytes = 2; | |
| 108 | + | } | |
| 109 | + | ||
| 98 | 110 | // MuxClientEnvelope is the closed broker-to-daemon mux union. Exactly one | |
| 99 | 111 | // variant must be set per envelope; empty, duplicate, and wrong-direction | |
| 100 | 112 | // payloads are rejected before generated unmarshal. | |
| @@ -104,6 +116,7 @@ message MuxClientEnvelope { | |||
| 104 | 116 | MuxData data = 302; | |
| 105 | 117 | MuxClose close = 303; | |
| 106 | 118 | MuxReset reset = 304; | |
| 119 | + | MuxWindowUpdate window_update = 305; | |
| 107 | 120 | } | |
| 108 | 121 | } | |
| 109 | 122 | ||
| @@ -116,6 +129,7 @@ message MuxServerEnvelope { | |||
| 116 | 129 | MuxData data = 403; | |
| 117 | 130 | MuxClose close = 404; | |
| 118 | 131 | MuxReset reset = 405; | |
| 132 | + | MuxWindowUpdate window_update = 406; | |
| 119 | 133 | } | |
| 120 | 134 | } | |
| 121 | 135 | ||
| @@ -4,13 +4,13 @@ | |||||
| 4 | // | 4 | // | ||
| 5 | // daemonmux is a third conversation, separate from the session protocol | 5 | // daemonmux is a third conversation, separate from the session protocol | ||
| 6 | // (tags 1-32) and the broker protocol (client 101-111, server 201-209): | 6 | // (tags 1-32) and the broker protocol (client 101-111, server 201-209): | ||
| 7 | - | // client tags 301-304 and server tags 401-405 are disjoint from every other | 7 | + | // client tags 301-305 and server tags 401-406 are disjoint from every other |
| 8 | // tag, and each direction is its own closed oneof union. The physical | 8 | // tag, and each direction is its own closed oneof union. The physical | ||
| 9 | // preamble is a separate adapter message pair, not an envelope variant: | 9 | // preamble is a separate adapter message pair, not an envelope variant: | ||
| 10 | // roles 5/6 negotiate the per-connection ceilings, the authenticated daemon | 10 | // roles 5/6 negotiate the per-connection ceilings, the authenticated daemon | ||
| 11 | // identity, and the accepted policy once, before any application message. | 11 | // identity, and the accepted policy once, before any application message. | ||
| 12 | // | 12 | // | ||
| 13 | - | // MuxData, MuxClose, and MuxReset are shared payloads carried in both | 13 | + | // MuxData, MuxClose, MuxReset, and MuxWindowUpdate are shared payloads carried in both |
| 14 | // directions; MuxOpen carries the full stream identity, and every later | 14 | // directions; MuxOpen carries the full stream identity, and every later | ||
| 15 | // frame is routed by physical_stream_id alone. | 15 | // frame is routed by physical_stream_id alone. | ||
| 16 | syntax = "proto3"; | 16 | syntax = "proto3"; | ||
| @@ -95,6 +95,18 @@ message MuxReset { | |||||
| 95 | BrokerErrorDetail error = 2; | 95 | BrokerErrorDetail error = 2; | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | + | // MuxWindowUpdate returns receive credit for one logical stream in either | |||
| 99 | + | // direction: the receiver consumed MuxData frames worth `credit_bytes` credit | |||
| 100 | + | // bytes, so the sender may spend that many more. One MuxData frame costs its | |||
| 101 | + | // payload length plus 64 credit bytes. Each stream starts with the same window | |||
| 102 | + | // on both sides, derived from the negotiated ceilings; a sender never has more | |||
| 103 | + | // credit in flight than its window, so a slow receiver slows the sender | |||
| 104 | + | // instead of overflowing its queue. | |||
| 105 | + | message MuxWindowUpdate { | |||
| 106 | + | uint64 physical_stream_id = 1; | |||
| 107 | + | uint64 credit_bytes = 2; | |||
| 108 | + | } | |||
| 109 | + | ||||
| 98 | // MuxClientEnvelope is the closed broker-to-daemon mux union. Exactly one | 110 | // MuxClientEnvelope is the closed broker-to-daemon mux union. Exactly one | ||
| 99 | // variant must be set per envelope; empty, duplicate, and wrong-direction | 111 | // variant must be set per envelope; empty, duplicate, and wrong-direction | ||
| 100 | // payloads are rejected before generated unmarshal. | 112 | // payloads are rejected before generated unmarshal. | ||
| @@ -104,6 +116,7 @@ message MuxClientEnvelope { | |||||
| 104 | MuxData data = 302; | 116 | MuxData data = 302; | ||
| 105 | MuxClose close = 303; | 117 | MuxClose close = 303; | ||
| 106 | MuxReset reset = 304; | 118 | MuxReset reset = 304; | ||
| 119 | + | MuxWindowUpdate window_update = 305; | |||
| 107 | } | 120 | } | ||
| 108 | } | 121 | } | ||
| 109 | 122 | ||||
| @@ -116,6 +129,7 @@ message MuxServerEnvelope { | |||||
| 116 | MuxData data = 403; | 129 | MuxData data = 403; | ||
| 117 | MuxClose close = 404; | 130 | MuxClose close = 404; | ||
| 118 | MuxReset reset = 405; | 131 | MuxReset reset = 405; | ||
| 132 | + | MuxWindowUpdate window_update = 406; | |||
| 119 | } | 133 | } | ||
| 120 | } | 134 | } | ||
| 121 | 135 | ||||
M internal/usecase/broker/pool.go +63 -14
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/binary" | |
| 6 | 6 | "errors" | |
| 7 | + | "log/slog" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -64,11 +65,27 @@ type reservation struct { | |||
| 64 | 65 | cancel context.CancelFunc | |
| 65 | 66 | entry *poolEntry | |
| 66 | 67 | } | |
| 68 | + | ||
| 69 | + | // retire records why one entry ends and cancels it with that reason. | |
| 70 | + | func (e *poolEntry) retire(reason error) func() { | |
| 71 | + | return func() { e.cancel(reason) } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Pool entry retirement reasons. Each one is the cancellation cause of the | |
| 75 | + | // entry context, so the streams it ends and the broker log name the reason. | |
| 76 | + | var ( | |
| 77 | + | errEntryIdle = errors.New("broker: pooled connection idle timeout") | |
| 78 | + | errEntryEvicted = errors.New("broker: pooled connection evicted for a newer warm connection") | |
| 79 | + | errEntryAbandoned = errors.New("broker: pooled connection abandoned before it was ready") | |
| 80 | + | errEntryRedirect = errors.New("broker: pooled connection merged into an existing connection") | |
| 81 | + | errEntryLost = errors.New("broker: pooled physical connection lost") | |
| 82 | + | ) | |
| 83 | + | ||
| 67 | 84 | type poolEntry struct { | |
| 68 | 85 | key poolKey | |
| 69 | 86 | pending pendingKey | |
| 70 | 87 | ctx context.Context | |
| 71 | - | cancel context.CancelFunc | |
| 88 | + | cancel context.CancelCauseFunc | |
| 72 | 89 | ready, wake chan struct{} | |
| 73 | 90 | physical ports.BrokerPhysicalConnection | |
| 74 | 91 | redirect *poolEntry | |
| @@ -200,8 +217,8 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest | |||
| 200 | 217 | p.mu.Lock() | |
| 201 | 218 | delete(c.streams, req.Stream) | |
| 202 | 219 | p.streams-- | |
| 203 | - | var abandon context.CancelFunc | |
| 204 | - | var evicted []context.CancelFunc | |
| 220 | + | var abandon func() | |
| 221 | + | var evicted []func() | |
| 205 | 222 | reservedEntry := r.entry | |
| 206 | 223 | if reservedEntry != nil { | |
| 207 | 224 | reservedEntry.refs-- | |
| @@ -211,7 +228,7 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest | |||
| 211 | 228 | evicted = p.idleLocked(reservedEntry) | |
| 212 | 229 | default: | |
| 213 | 230 | reservedEntry.retiring = true | |
| 214 | - | abandon = reservedEntry.cancel | |
| 231 | + | abandon = reservedEntry.retire(errEntryAbandoned) | |
| 215 | 232 | } | |
| 216 | 233 | } | |
| 217 | 234 | select { | |
| @@ -269,7 +286,7 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest | |||
| 269 | 286 | p.mu.Unlock() | |
| 270 | 287 | return nil, ports.BrokerAdmissionLimit | |
| 271 | 288 | } | |
| 272 | - | ectx, ecancel := context.WithCancel(p.ctx) | |
| 289 | + | ectx, ecancel := context.WithCancelCause(p.ctx) | |
| 273 | 290 | entry = &poolEntry{key: key, pending: pending, ctx: ectx, cancel: ecancel, ready: make(chan struct{}), wake: make(chan struct{}, 1)} | |
| 274 | 291 | if resolved.ExpectedIdentity.Bound { | |
| 275 | 292 | p.entries[key] = entry | |
| @@ -332,7 +349,6 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest | |||
| 332 | 349 | case <-ctx.Done(): | |
| 333 | 350 | terminal = ports.BrokerError{Code: ports.BrokerErrorCancelled} | |
| 334 | 351 | case <-entry.ctx.Done(): | |
| 335 | - | terminal = ports.BrokerError{Code: ports.BrokerErrorAttachmentLost} | |
| 336 | 352 | case <-entry.physical.Done(): | |
| 337 | 353 | case <-raw.Done(): | |
| 338 | 354 | terminal = raw.Err() | |
| @@ -394,7 +410,7 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) { | |||
| 394 | 410 | p.mu.Unlock() | |
| 395 | 411 | if redirect != nil { | |
| 396 | 412 | _ = physical.Close() | |
| 397 | - | e.cancel() | |
| 413 | + | e.cancel(errEntryRedirect) | |
| 398 | 414 | return | |
| 399 | 415 | } | |
| 400 | 416 | if err == nil { | |
| @@ -421,6 +437,7 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) { | |||
| 421 | 437 | case <-e.ctx.Done(): | |
| 422 | 438 | goto retire | |
| 423 | 439 | case <-physical.Done(): | |
| 440 | + | e.cancel(errEntryLost) | |
| 424 | 441 | goto retire | |
| 425 | 442 | case <-e.wake: | |
| 426 | 443 | continue | |
| @@ -429,6 +446,7 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) { | |||
| 429 | 446 | if e.refs == 0 { | |
| 430 | 447 | e.retiring = true | |
| 431 | 448 | p.mu.Unlock() | |
| 449 | + | e.cancel(errEntryIdle) | |
| 432 | 450 | goto retire | |
| 433 | 451 | } | |
| 434 | 452 | p.mu.Unlock() | |
| @@ -438,9 +456,15 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) { | |||
| 438 | 456 | retire: | |
| 439 | 457 | p.mu.Lock() | |
| 440 | 458 | e.retiring = true | |
| 459 | + | refs := e.refs | |
| 441 | 460 | p.mu.Unlock() | |
| 442 | - | e.cancel() | |
| 461 | + | if err != nil { | |
| 462 | + | e.cancel(err) | |
| 463 | + | } else { | |
| 464 | + | e.cancel(errEntryLost) | |
| 465 | + | } | |
| 443 | 466 | if physical != nil { | |
| 467 | + | p.logRetire(e, physical, refs) | |
| 444 | 468 | _ = physical.Close() | |
| 445 | 469 | } | |
| 446 | 470 | // Keep the key occupied until Close completes: never overlap physicals. | |
| @@ -458,7 +482,7 @@ retire: | |||
| 458 | 482 | // and returns the cancellations that retire warm entries beyond limits.Warm, | |
| 459 | 483 | // least recently idled first. The caller runs them after unlocking. An entry | |
| 460 | 484 | // that failed, lost a redirect race, or is already retiring is never warm. | |
| 461 | - | func (p *Pool) idleLocked(e *poolEntry) []context.CancelFunc { | |
| 485 | + | func (p *Pool) idleLocked(e *poolEntry) []func() { | |
| 462 | 486 | if e.err != nil || e.redirect != nil || e.retiring || p.entries[e.key] != e { | |
| 463 | 487 | return nil | |
| 464 | 488 | } | |
| @@ -470,7 +494,7 @@ func (p *Pool) idleLocked(e *poolEntry) []context.CancelFunc { | |||
| 470 | 494 | warm = append(warm, candidate) | |
| 471 | 495 | } | |
| 472 | 496 | } | |
| 473 | - | var evicted []context.CancelFunc | |
| 497 | + | var evicted []func() | |
| 474 | 498 | for len(warm) > p.limits.Warm { | |
| 475 | 499 | oldest := 0 | |
| 476 | 500 | for i := range warm { | |
| @@ -480,7 +504,7 @@ func (p *Pool) idleLocked(e *poolEntry) []context.CancelFunc { | |||
| 480 | 504 | } | |
| 481 | 505 | victim := warm[oldest] | |
| 482 | 506 | victim.retiring = true | |
| 483 | - | evicted = append(evicted, victim.cancel) | |
| 507 | + | evicted = append(evicted, victim.retire(errEntryEvicted)) | |
| 484 | 508 | warm = append(warm[:oldest], warm[oldest+1:]...) | |
| 485 | 509 | } | |
| 486 | 510 | return evicted | |
| @@ -505,7 +529,7 @@ func (p *Pool) AdoptPhysical(physical ports.BrokerPhysicalConnection, policy por | |||
| 505 | 529 | p.mu.Unlock() | |
| 506 | 530 | return false | |
| 507 | 531 | } | |
| 508 | - | ctx, cancel := context.WithCancel(p.ctx) | |
| 532 | + | ctx, cancel := context.WithCancelCause(p.ctx) | |
| 509 | 533 | e := &poolEntry{key: key, ctx: ctx, cancel: cancel, ready: make(chan struct{}), wake: make(chan struct{}, 1), physical: physical} | |
| 510 | 534 | close(e.ready) | |
| 511 | 535 | p.entries[key] = e | |
| @@ -543,6 +567,7 @@ func (p *Pool) watchAdopted(e *poolEntry) { | |||
| 543 | 567 | case <-e.ctx.Done(): | |
| 544 | 568 | goto retire | |
| 545 | 569 | case <-physical.Done(): | |
| 570 | + | e.cancel(errEntryLost) | |
| 546 | 571 | goto retire | |
| 547 | 572 | case <-e.wake: | |
| 548 | 573 | continue | |
| @@ -551,6 +576,7 @@ func (p *Pool) watchAdopted(e *poolEntry) { | |||
| 551 | 576 | if e.refs == 0 && e.idleOrder != 0 { | |
| 552 | 577 | e.retiring = true | |
| 553 | 578 | p.mu.Unlock() | |
| 579 | + | e.cancel(errEntryIdle) | |
| 554 | 580 | goto retire | |
| 555 | 581 | } | |
| 556 | 582 | p.mu.Unlock() | |
| @@ -559,8 +585,10 @@ func (p *Pool) watchAdopted(e *poolEntry) { | |||
| 559 | 585 | retire: | |
| 560 | 586 | p.mu.Lock() | |
| 561 | 587 | e.retiring = true | |
| 588 | + | refs := e.refs | |
| 562 | 589 | p.mu.Unlock() | |
| 563 | - | e.cancel() | |
| 590 | + | e.cancel(errEntryLost) | |
| 591 | + | p.logRetire(e, physical, refs) | |
| 564 | 592 | _ = physical.Close() | |
| 565 | 593 | p.mu.Lock() | |
| 566 | 594 | if p.entries[e.key] == e { | |
| @@ -597,6 +625,26 @@ func (p *Pool) SharedPhysical(identity ports.BrokerDaemonIdentity, policy ports. | |||
| 597 | 625 | return e.physical, true | |
| 598 | 626 | } | |
| 599 | 627 | ||
| 628 | + | // logRetire records one physical connection leaving the pool: why the entry | |
| 629 | + | // ended, the transport's own failure when it died, and how many streams were | |
| 630 | + | // still using it. Pool shutdown is not logged. | |
| 631 | + | func (p *Pool) logRetire(e *poolEntry, physical ports.BrokerPhysicalConnection, refs int) { | |
| 632 | + | if p.ctx.Err() != nil { | |
| 633 | + | return | |
| 634 | + | } | |
| 635 | + | level := slog.LevelInfo | |
| 636 | + | if refs > 0 { | |
| 637 | + | level = slog.LevelWarn | |
| 638 | + | } | |
| 639 | + | args := []any{"reason", context.Cause(e.ctx), "active_streams", refs} | |
| 640 | + | select { | |
| 641 | + | case <-physical.Done(): | |
| 642 | + | args = append(args, "failure", physical.FailureKind().String(), "cause", physical.Err()) | |
| 643 | + | default: | |
| 644 | + | } | |
| 645 | + | slog.Log(context.Background(), level, "broker_pool_connection_retired", args...) | |
| 646 | + | } | |
| 647 | + | ||
| 600 | 648 | // Close cancels pending operations, closes transports, and joins all owned | |
| 601 | 649 | // workers. Bounded completion depends on the explicit cancellation/Close port | |
| 602 | 650 | // contract; callers must not supply adapters that ignore it. | |
| @@ -682,7 +730,8 @@ func (p *Pool) streamTerminal(ctx context.Context, e *poolEntry, req ports.Broke | |||
| 682 | 730 | default: | |
| 683 | 731 | } | |
| 684 | 732 | if e.ctx.Err() != nil { | |
| 685 | - | return ports.BrokerError{Code: ports.BrokerErrorAttachmentLost} | |
| 733 | + | reason := context.Cause(e.ctx) | |
| 734 | + | return ports.BrokerError{Code: ports.BrokerErrorAttachmentLost, Text: reason.Error(), Cause: reason} | |
| 686 | 735 | } | |
| 687 | 736 | return normalizePoolError(fallback) | |
| 688 | 737 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/binary" | 5 | "encoding/binary" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "log/slog" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| @@ -64,11 +65,27 @@ type reservation struct { | |||||
| 64 | cancel context.CancelFunc | 65 | cancel context.CancelFunc | ||
| 65 | entry *poolEntry | 66 | entry *poolEntry | ||
| 66 | } | 67 | } | ||
| 68 | + | ||||
| 69 | + | // retire records why one entry ends and cancels it with that reason. | |||
| 70 | + | func (e *poolEntry) retire(reason error) func() { | |||
| 71 | + | return func() { e.cancel(reason) } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Pool entry retirement reasons. Each one is the cancellation cause of the | |||
| 75 | + | // entry context, so the streams it ends and the broker log name the reason. | |||
| 76 | + | var ( | |||
| 77 | + | errEntryIdle = errors.New("broker: pooled connection idle timeout") | |||
| 78 | + | errEntryEvicted = errors.New("broker: pooled connection evicted for a newer warm connection") | |||
| 79 | + | errEntryAbandoned = errors.New("broker: pooled connection abandoned before it was ready") | |||
| 80 | + | errEntryRedirect = errors.New("broker: pooled connection merged into an existing connection") | |||
| 81 | + | errEntryLost = errors.New("broker: pooled physical connection lost") | |||
| 82 | + | ) | |||
| 83 | + | ||||
| 67 | type poolEntry struct { | 84 | type poolEntry struct { | ||
| 68 | key poolKey | 85 | key poolKey | ||
| 69 | pending pendingKey | 86 | pending pendingKey | ||
| 70 | ctx context.Context | 87 | ctx context.Context | ||
| 71 | - | cancel context.CancelFunc | 88 | + | cancel context.CancelCauseFunc |
| 72 | ready, wake chan struct{} | 89 | ready, wake chan struct{} | ||
| 73 | physical ports.BrokerPhysicalConnection | 90 | physical ports.BrokerPhysicalConnection | ||
| 74 | redirect *poolEntry | 91 | redirect *poolEntry | ||
| @@ -200,8 +217,8 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest | |||||
| 200 | p.mu.Lock() | 217 | p.mu.Lock() | ||
| 201 | delete(c.streams, req.Stream) | 218 | delete(c.streams, req.Stream) | ||
| 202 | p.streams-- | 219 | p.streams-- | ||
| 203 | - | var abandon context.CancelFunc | 220 | + | var abandon func() |
| 204 | - | var evicted []context.CancelFunc | 221 | + | var evicted []func() |
| 205 | reservedEntry := r.entry | 222 | reservedEntry := r.entry | ||
| 206 | if reservedEntry != nil { | 223 | if reservedEntry != nil { | ||
| 207 | reservedEntry.refs-- | 224 | reservedEntry.refs-- | ||
| @@ -211,7 +228,7 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest | |||||
| 211 | evicted = p.idleLocked(reservedEntry) | 228 | evicted = p.idleLocked(reservedEntry) | ||
| 212 | default: | 229 | default: | ||
| 213 | reservedEntry.retiring = true | 230 | reservedEntry.retiring = true | ||
| 214 | - | abandon = reservedEntry.cancel | 231 | + | abandon = reservedEntry.retire(errEntryAbandoned) |
| 215 | } | 232 | } | ||
| 216 | } | 233 | } | ||
| 217 | select { | 234 | select { | ||
| @@ -269,7 +286,7 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest | |||||
| 269 | p.mu.Unlock() | 286 | p.mu.Unlock() | ||
| 270 | return nil, ports.BrokerAdmissionLimit | 287 | return nil, ports.BrokerAdmissionLimit | ||
| 271 | } | 288 | } | ||
| 272 | - | ectx, ecancel := context.WithCancel(p.ctx) | 289 | + | ectx, ecancel := context.WithCancelCause(p.ctx) |
| 273 | entry = &poolEntry{key: key, pending: pending, ctx: ectx, cancel: ecancel, ready: make(chan struct{}), wake: make(chan struct{}, 1)} | 290 | entry = &poolEntry{key: key, pending: pending, ctx: ectx, cancel: ecancel, ready: make(chan struct{}), wake: make(chan struct{}, 1)} | ||
| 274 | if resolved.ExpectedIdentity.Bound { | 291 | if resolved.ExpectedIdentity.Bound { | ||
| 275 | p.entries[key] = entry | 292 | p.entries[key] = entry | ||
| @@ -332,7 +349,6 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest | |||||
| 332 | case <-ctx.Done(): | 349 | case <-ctx.Done(): | ||
| 333 | terminal = ports.BrokerError{Code: ports.BrokerErrorCancelled} | 350 | terminal = ports.BrokerError{Code: ports.BrokerErrorCancelled} | ||
| 334 | case <-entry.ctx.Done(): | 351 | case <-entry.ctx.Done(): | ||
| 335 | - | terminal = ports.BrokerError{Code: ports.BrokerErrorAttachmentLost} | |||
| 336 | case <-entry.physical.Done(): | 352 | case <-entry.physical.Done(): | ||
| 337 | case <-raw.Done(): | 353 | case <-raw.Done(): | ||
| 338 | terminal = raw.Err() | 354 | terminal = raw.Err() | ||
| @@ -394,7 +410,7 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) { | |||||
| 394 | p.mu.Unlock() | 410 | p.mu.Unlock() | ||
| 395 | if redirect != nil { | 411 | if redirect != nil { | ||
| 396 | _ = physical.Close() | 412 | _ = physical.Close() | ||
| 397 | - | e.cancel() | 413 | + | e.cancel(errEntryRedirect) |
| 398 | return | 414 | return | ||
| 399 | } | 415 | } | ||
| 400 | if err == nil { | 416 | if err == nil { | ||
| @@ -421,6 +437,7 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) { | |||||
| 421 | case <-e.ctx.Done(): | 437 | case <-e.ctx.Done(): | ||
| 422 | goto retire | 438 | goto retire | ||
| 423 | case <-physical.Done(): | 439 | case <-physical.Done(): | ||
| 440 | + | e.cancel(errEntryLost) | |||
| 424 | goto retire | 441 | goto retire | ||
| 425 | case <-e.wake: | 442 | case <-e.wake: | ||
| 426 | continue | 443 | continue | ||
| @@ -429,6 +446,7 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) { | |||||
| 429 | if e.refs == 0 { | 446 | if e.refs == 0 { | ||
| 430 | e.retiring = true | 447 | e.retiring = true | ||
| 431 | p.mu.Unlock() | 448 | p.mu.Unlock() | ||
| 449 | + | e.cancel(errEntryIdle) | |||
| 432 | goto retire | 450 | goto retire | ||
| 433 | } | 451 | } | ||
| 434 | p.mu.Unlock() | 452 | p.mu.Unlock() | ||
| @@ -438,9 +456,15 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) { | |||||
| 438 | retire: | 456 | retire: | ||
| 439 | p.mu.Lock() | 457 | p.mu.Lock() | ||
| 440 | e.retiring = true | 458 | e.retiring = true | ||
| 459 | + | refs := e.refs | |||
| 441 | p.mu.Unlock() | 460 | p.mu.Unlock() | ||
| 442 | - | e.cancel() | 461 | + | if err != nil { |
| 462 | + | e.cancel(err) | |||
| 463 | + | } else { | |||
| 464 | + | e.cancel(errEntryLost) | |||
| 465 | + | } | |||
| 443 | if physical != nil { | 466 | if physical != nil { | ||
| 467 | + | p.logRetire(e, physical, refs) | |||
| 444 | _ = physical.Close() | 468 | _ = physical.Close() | ||
| 445 | } | 469 | } | ||
| 446 | // Keep the key occupied until Close completes: never overlap physicals. | 470 | // Keep the key occupied until Close completes: never overlap physicals. | ||
| @@ -458,7 +482,7 @@ retire: | |||||
| 458 | // and returns the cancellations that retire warm entries beyond limits.Warm, | 482 | // and returns the cancellations that retire warm entries beyond limits.Warm, | ||
| 459 | // least recently idled first. The caller runs them after unlocking. An entry | 483 | // least recently idled first. The caller runs them after unlocking. An entry | ||
| 460 | // that failed, lost a redirect race, or is already retiring is never warm. | 484 | // that failed, lost a redirect race, or is already retiring is never warm. | ||
| 461 | - | func (p *Pool) idleLocked(e *poolEntry) []context.CancelFunc { | 485 | + | func (p *Pool) idleLocked(e *poolEntry) []func() { |
| 462 | if e.err != nil || e.redirect != nil || e.retiring || p.entries[e.key] != e { | 486 | if e.err != nil || e.redirect != nil || e.retiring || p.entries[e.key] != e { | ||
| 463 | return nil | 487 | return nil | ||
| 464 | } | 488 | } | ||
| @@ -470,7 +494,7 @@ func (p *Pool) idleLocked(e *poolEntry) []context.CancelFunc { | |||||
| 470 | warm = append(warm, candidate) | 494 | warm = append(warm, candidate) | ||
| 471 | } | 495 | } | ||
| 472 | } | 496 | } | ||
| 473 | - | var evicted []context.CancelFunc | 497 | + | var evicted []func() |
| 474 | for len(warm) > p.limits.Warm { | 498 | for len(warm) > p.limits.Warm { | ||
| 475 | oldest := 0 | 499 | oldest := 0 | ||
| 476 | for i := range warm { | 500 | for i := range warm { | ||
| @@ -480,7 +504,7 @@ func (p *Pool) idleLocked(e *poolEntry) []context.CancelFunc { | |||||
| 480 | } | 504 | } | ||
| 481 | victim := warm[oldest] | 505 | victim := warm[oldest] | ||
| 482 | victim.retiring = true | 506 | victim.retiring = true | ||
| 483 | - | evicted = append(evicted, victim.cancel) | 507 | + | evicted = append(evicted, victim.retire(errEntryEvicted)) |
| 484 | warm = append(warm[:oldest], warm[oldest+1:]...) | 508 | warm = append(warm[:oldest], warm[oldest+1:]...) | ||
| 485 | } | 509 | } | ||
| 486 | return evicted | 510 | return evicted | ||
| @@ -505,7 +529,7 @@ func (p *Pool) AdoptPhysical(physical ports.BrokerPhysicalConnection, policy por | |||||
| 505 | p.mu.Unlock() | 529 | p.mu.Unlock() | ||
| 506 | return false | 530 | return false | ||
| 507 | } | 531 | } | ||
| 508 | - | ctx, cancel := context.WithCancel(p.ctx) | 532 | + | ctx, cancel := context.WithCancelCause(p.ctx) |
| 509 | e := &poolEntry{key: key, ctx: ctx, cancel: cancel, ready: make(chan struct{}), wake: make(chan struct{}, 1), physical: physical} | 533 | e := &poolEntry{key: key, ctx: ctx, cancel: cancel, ready: make(chan struct{}), wake: make(chan struct{}, 1), physical: physical} | ||
| 510 | close(e.ready) | 534 | close(e.ready) | ||
| 511 | p.entries[key] = e | 535 | p.entries[key] = e | ||
| @@ -543,6 +567,7 @@ func (p *Pool) watchAdopted(e *poolEntry) { | |||||
| 543 | case <-e.ctx.Done(): | 567 | case <-e.ctx.Done(): | ||
| 544 | goto retire | 568 | goto retire | ||
| 545 | case <-physical.Done(): | 569 | case <-physical.Done(): | ||
| 570 | + | e.cancel(errEntryLost) | |||
| 546 | goto retire | 571 | goto retire | ||
| 547 | case <-e.wake: | 572 | case <-e.wake: | ||
| 548 | continue | 573 | continue | ||
| @@ -551,6 +576,7 @@ func (p *Pool) watchAdopted(e *poolEntry) { | |||||
| 551 | if e.refs == 0 && e.idleOrder != 0 { | 576 | if e.refs == 0 && e.idleOrder != 0 { | ||
| 552 | e.retiring = true | 577 | e.retiring = true | ||
| 553 | p.mu.Unlock() | 578 | p.mu.Unlock() | ||
| 579 | + | e.cancel(errEntryIdle) | |||
| 554 | goto retire | 580 | goto retire | ||
| 555 | } | 581 | } | ||
| 556 | p.mu.Unlock() | 582 | p.mu.Unlock() | ||
| @@ -559,8 +585,10 @@ func (p *Pool) watchAdopted(e *poolEntry) { | |||||
| 559 | retire: | 585 | retire: | ||
| 560 | p.mu.Lock() | 586 | p.mu.Lock() | ||
| 561 | e.retiring = true | 587 | e.retiring = true | ||
| 588 | + | refs := e.refs | |||
| 562 | p.mu.Unlock() | 589 | p.mu.Unlock() | ||
| 563 | - | e.cancel() | 590 | + | e.cancel(errEntryLost) |
| 591 | + | p.logRetire(e, physical, refs) | |||
| 564 | _ = physical.Close() | 592 | _ = physical.Close() | ||
| 565 | p.mu.Lock() | 593 | p.mu.Lock() | ||
| 566 | if p.entries[e.key] == e { | 594 | if p.entries[e.key] == e { | ||
| @@ -597,6 +625,26 @@ func (p *Pool) SharedPhysical(identity ports.BrokerDaemonIdentity, policy ports. | |||||
| 597 | return e.physical, true | 625 | return e.physical, true | ||
| 598 | } | 626 | } | ||
| 599 | 627 | ||||
| 628 | + | // logRetire records one physical connection leaving the pool: why the entry | |||
| 629 | + | // ended, the transport's own failure when it died, and how many streams were | |||
| 630 | + | // still using it. Pool shutdown is not logged. | |||
| 631 | + | func (p *Pool) logRetire(e *poolEntry, physical ports.BrokerPhysicalConnection, refs int) { | |||
| 632 | + | if p.ctx.Err() != nil { | |||
| 633 | + | return | |||
| 634 | + | } | |||
| 635 | + | level := slog.LevelInfo | |||
| 636 | + | if refs > 0 { | |||
| 637 | + | level = slog.LevelWarn | |||
| 638 | + | } | |||
| 639 | + | args := []any{"reason", context.Cause(e.ctx), "active_streams", refs} | |||
| 640 | + | select { | |||
| 641 | + | case <-physical.Done(): | |||
| 642 | + | args = append(args, "failure", physical.FailureKind().String(), "cause", physical.Err()) | |||
| 643 | + | default: | |||
| 644 | + | } | |||
| 645 | + | slog.Log(context.Background(), level, "broker_pool_connection_retired", args...) | |||
| 646 | + | } | |||
| 647 | + | ||||
| 600 | // Close cancels pending operations, closes transports, and joins all owned | 648 | // Close cancels pending operations, closes transports, and joins all owned | ||
| 601 | // workers. Bounded completion depends on the explicit cancellation/Close port | 649 | // workers. Bounded completion depends on the explicit cancellation/Close port | ||
| 602 | // contract; callers must not supply adapters that ignore it. | 650 | // contract; callers must not supply adapters that ignore it. | ||
| @@ -682,7 +730,8 @@ func (p *Pool) streamTerminal(ctx context.Context, e *poolEntry, req ports.Broke | |||||
| 682 | default: | 730 | default: | ||
| 683 | } | 731 | } | ||
| 684 | if e.ctx.Err() != nil { | 732 | if e.ctx.Err() != nil { | ||
| 685 | - | return ports.BrokerError{Code: ports.BrokerErrorAttachmentLost} | 733 | + | reason := context.Cause(e.ctx) |
| 734 | + | return ports.BrokerError{Code: ports.BrokerErrorAttachmentLost, Text: reason.Error(), Cause: reason} | |||
| 686 | } | 735 | } | ||
| 687 | return normalizePoolError(fallback) | 736 | return normalizePoolError(fallback) | ||
| 688 | } | 737 | } | ||
M internal/usecase/broker/pool_review_test.go +31 -0
| @@ -171,6 +171,37 @@ func TestPoolTerminalPrecedence(t *testing.T) { | |||
| 171 | 171 | } | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | + | // TestPoolRetiredEntryNamesReason proves a stream ended by an entry retirement | |
| 175 | + | // carries the retirement reason, not a bare attachment_lost. | |
| 176 | + | func TestPoolRetiredEntryNamesReason(t *testing.T) { | |
| 177 | + | cases := []struct { | |
| 178 | + | name string | |
| 179 | + | reason error | |
| 180 | + | }{ | |
| 181 | + | {"idle", errEntryIdle}, | |
| 182 | + | {"evicted", errEntryEvicted}, | |
| 183 | + | {"abandoned", errEntryAbandoned}, | |
| 184 | + | } | |
| 185 | + | for _, tc := range cases { | |
| 186 | + | t.Run(tc.name, func(t *testing.T) { | |
| 187 | + | p, _ := setupPool(t, func(context.Context, ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) { | |
| 188 | + | panic("unused") | |
| 189 | + | }) | |
| 190 | + | ctx, cancel := context.WithCancelCause(context.Background()) | |
| 191 | + | e := &poolEntry{ctx: ctx, cancel: cancel, physical: &fakePhysical{done: make(chan struct{})}} | |
| 192 | + | e.retire(tc.reason)() | |
| 193 | + | ||
| 194 | + | err := p.streamTerminal(context.Background(), e, poolRequest(ports.BrokerConnectionID{1}, 1), nil) | |
| 195 | + | var typed ports.BrokerError | |
| 196 | + | require.ErrorAs(t, err, &typed) | |
| 197 | + | require.Equal(t, ports.BrokerErrorAttachmentLost, typed.Code) | |
| 198 | + | require.Equal(t, tc.reason.Error(), typed.Text) | |
| 199 | + | require.NoError(t, typed.Validate()) | |
| 200 | + | require.ErrorIs(t, err, tc.reason) | |
| 201 | + | }) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 174 | 205 | // Reset deliberately does not drain, matching older buffered timer adapters. | |
| 175 | 206 | type bufferedPoolTimer struct { | |
| 176 | 207 | ch chan time.Time | |
| @@ -171,6 +171,37 @@ func TestPoolTerminalPrecedence(t *testing.T) { | |||||
| 171 | } | 171 | } | ||
| 172 | } | 172 | } | ||
| 173 | 173 | ||||
| 174 | + | // TestPoolRetiredEntryNamesReason proves a stream ended by an entry retirement | |||
| 175 | + | // carries the retirement reason, not a bare attachment_lost. | |||
| 176 | + | func TestPoolRetiredEntryNamesReason(t *testing.T) { | |||
| 177 | + | cases := []struct { | |||
| 178 | + | name string | |||
| 179 | + | reason error | |||
| 180 | + | }{ | |||
| 181 | + | {"idle", errEntryIdle}, | |||
| 182 | + | {"evicted", errEntryEvicted}, | |||
| 183 | + | {"abandoned", errEntryAbandoned}, | |||
| 184 | + | } | |||
| 185 | + | for _, tc := range cases { | |||
| 186 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 187 | + | p, _ := setupPool(t, func(context.Context, ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) { | |||
| 188 | + | panic("unused") | |||
| 189 | + | }) | |||
| 190 | + | ctx, cancel := context.WithCancelCause(context.Background()) | |||
| 191 | + | e := &poolEntry{ctx: ctx, cancel: cancel, physical: &fakePhysical{done: make(chan struct{})}} | |||
| 192 | + | e.retire(tc.reason)() | |||
| 193 | + | ||||
| 194 | + | err := p.streamTerminal(context.Background(), e, poolRequest(ports.BrokerConnectionID{1}, 1), nil) | |||
| 195 | + | var typed ports.BrokerError | |||
| 196 | + | require.ErrorAs(t, err, &typed) | |||
| 197 | + | require.Equal(t, ports.BrokerErrorAttachmentLost, typed.Code) | |||
| 198 | + | require.Equal(t, tc.reason.Error(), typed.Text) | |||
| 199 | + | require.NoError(t, typed.Validate()) | |||
| 200 | + | require.ErrorIs(t, err, tc.reason) | |||
| 201 | + | }) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 174 | // Reset deliberately does not drain, matching older buffered timer adapters. | 205 | // Reset deliberately does not drain, matching older buffered timer adapters. | ||
| 175 | type bufferedPoolTimer struct { | 206 | type bufferedPoolTimer struct { | ||
| 176 | ch chan time.Time | 207 | ch chan time.Time | ||
M internal/usecase/broker/registry_test.go +7 -2
| @@ -581,12 +581,17 @@ func startRegistry(t *testing.T, r *Registry) context.CancelFunc { | |||
| 581 | 581 | return cancel | |
| 582 | 582 | } | |
| 583 | 583 | ||
| 584 | + | // registryTestWait bounds how long a test waits for an expected probe or | |
| 585 | + | // snapshot. It only delays a failing test, so it is generous for loaded | |
| 586 | + | // race-enabled CI runners. | |
| 587 | + | const registryTestWait = 5 * time.Second | |
| 588 | + | ||
| 584 | 589 | func receiveCall(t *testing.T, probe *testProbe) probeCall { | |
| 585 | 590 | t.Helper() | |
| 586 | 591 | select { | |
| 587 | 592 | case call := <-probe.calls: | |
| 588 | 593 | return call | |
| 589 | - | case <-time.After(time.Second): | |
| 594 | + | case <-time.After(registryTestWait): | |
| 590 | 595 | t.Fatal("timed out waiting for probe") | |
| 591 | 596 | return probeCall{} | |
| 592 | 597 | } | |
| @@ -607,7 +612,7 @@ func waitSnapshot(t *testing.T, r *Registry, predicate func(ports.BrokerSnapshot | |||
| 607 | 612 | t.Helper() | |
| 608 | 613 | sub := r.Subscribe() | |
| 609 | 614 | defer sub.Close() | |
| 610 | - | deadline := time.After(time.Second) | |
| 615 | + | deadline := time.After(registryTestWait) | |
| 611 | 616 | for { | |
| 612 | 617 | snapshot := r.Snapshot() | |
| 613 | 618 | if predicate(snapshot) { | |
| @@ -581,12 +581,17 @@ func startRegistry(t *testing.T, r *Registry) context.CancelFunc { | |||||
| 581 | return cancel | 581 | return cancel | ||
| 582 | } | 582 | } | ||
| 583 | 583 | ||||
| 584 | + | // registryTestWait bounds how long a test waits for an expected probe or | |||
| 585 | + | // snapshot. It only delays a failing test, so it is generous for loaded | |||
| 586 | + | // race-enabled CI runners. | |||
| 587 | + | const registryTestWait = 5 * time.Second | |||
| 588 | + | ||||
| 584 | func receiveCall(t *testing.T, probe *testProbe) probeCall { | 589 | func receiveCall(t *testing.T, probe *testProbe) probeCall { | ||
| 585 | t.Helper() | 590 | t.Helper() | ||
| 586 | select { | 591 | select { | ||
| 587 | case call := <-probe.calls: | 592 | case call := <-probe.calls: | ||
| 588 | return call | 593 | return call | ||
| 589 | - | case <-time.After(time.Second): | 594 | + | case <-time.After(registryTestWait): |
| 590 | t.Fatal("timed out waiting for probe") | 595 | t.Fatal("timed out waiting for probe") | ||
| 591 | return probeCall{} | 596 | return probeCall{} | ||
| 592 | } | 597 | } | ||
| @@ -607,7 +612,7 @@ func waitSnapshot(t *testing.T, r *Registry, predicate func(ports.BrokerSnapshot | |||||
| 607 | t.Helper() | 612 | t.Helper() | ||
| 608 | sub := r.Subscribe() | 613 | sub := r.Subscribe() | ||
| 609 | defer sub.Close() | 614 | defer sub.Close() | ||
| 610 | - | deadline := time.After(time.Second) | 615 | + | deadline := time.After(registryTestWait) |
| 611 | for { | 616 | for { | ||
| 612 | snapshot := r.Snapshot() | 617 | snapshot := r.Snapshot() | ||
| 613 | if predicate(snapshot) { | 618 | if predicate(snapshot) { | ||
M internal/usecase/client/attachment_worker.go +12 -0
| @@ -915,6 +915,18 @@ func (f *attachmentForeground) overlayRepaint() <-chan struct{} { | |||
| 915 | 915 | return f.repaint | |
| 916 | 916 | } | |
| 917 | 917 | ||
| 918 | + | // committedSnapshot reports the session and tab this foreground last | |
| 919 | + | // committed output for. It stays readable after the foreground is finished, | |
| 920 | + | // so a settled attachment can still name what it was showing. | |
| 921 | + | func (f *attachmentForeground) committedSnapshot() (protocol.ExactSessionTarget, domain.TabStableID, bool) { | |
| 922 | + | if f == nil { | |
| 923 | + | return protocol.ExactSessionTarget{}, "", false | |
| 924 | + | } | |
| 925 | + | f.overlayMu.Lock() | |
| 926 | + | defer f.overlayMu.Unlock() | |
| 927 | + | return f.committed, f.committedTab, f.committedKnown | |
| 928 | + | } | |
| 929 | + | ||
| 918 | 930 | func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) { | |
| 919 | 931 | if f == nil { | |
| 920 | 932 | return | |
| @@ -915,6 +915,18 @@ func (f *attachmentForeground) overlayRepaint() <-chan struct{} { | |||||
| 915 | return f.repaint | 915 | return f.repaint | ||
| 916 | } | 916 | } | ||
| 917 | 917 | ||||
| 918 | + | // committedSnapshot reports the session and tab this foreground last | |||
| 919 | + | // committed output for. It stays readable after the foreground is finished, | |||
| 920 | + | // so a settled attachment can still name what it was showing. | |||
| 921 | + | func (f *attachmentForeground) committedSnapshot() (protocol.ExactSessionTarget, domain.TabStableID, bool) { | |||
| 922 | + | if f == nil { | |||
| 923 | + | return protocol.ExactSessionTarget{}, "", false | |||
| 924 | + | } | |||
| 925 | + | f.overlayMu.Lock() | |||
| 926 | + | defer f.overlayMu.Unlock() | |||
| 927 | + | return f.committed, f.committedTab, f.committedKnown | |||
| 928 | + | } | |||
| 929 | + | ||||
| 918 | func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) { | 930 | func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) { | ||
| 919 | if f == nil { | 931 | if f == nil { | ||
| 920 | return | 932 | return | ||
M internal/usecase/client/lifecycle_p54a_test.go +2 -4
| @@ -2,7 +2,6 @@ package client | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "sync" | |
| 7 | 6 | "testing" | |
| 8 | 7 | "time" | |
| @@ -64,9 +63,8 @@ func TestP54AttachmentOutcomesReturnToPicker(t *testing.T) { | |||
| 64 | 63 | // the client instead of returning to the picker. | |
| 65 | 64 | {name: "server shutdown", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonServerShutdown}) }, notice: LifecycleNoticeSessionEnded}, | |
| 66 | 65 | {name: "session deletion", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonSessionKilled}) }, notice: LifecycleNoticeSessionEnded}, | |
| 67 | - | {name: "destination failure", settle: func(s *sessionTestStream) { | |
| 68 | - | s.fail(ports.BrokerStreamLost{Connection: ports.BrokerConnectionID{1}, Stream: 1, Epoch: 3, Cause: domain.RemoteFailureTransport, Err: errors.New("private diagnostic")}) | |
| 69 | - | }, notice: LifecycleNoticeDestinationFailed}, | |
| 66 | + | // A stream loss after attachment resumes the same session instead: | |
| 67 | + | // TestSupervisorAttachmentLossResumesSameSession pins that path. | |
| 70 | 68 | } { | |
| 71 | 69 | t.Run(tc.name, func(t *testing.T) { | |
| 72 | 70 | picker := newAttachTestPicker() | |
| @@ -2,7 +2,6 @@ package client | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "errors" | |||
| 6 | "sync" | 5 | "sync" | ||
| 7 | "testing" | 6 | "testing" | ||
| 8 | "time" | 7 | "time" | ||
| @@ -64,9 +63,8 @@ func TestP54AttachmentOutcomesReturnToPicker(t *testing.T) { | |||||
| 64 | // the client instead of returning to the picker. | 63 | // the client instead of returning to the picker. | ||
| 65 | {name: "server shutdown", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonServerShutdown}) }, notice: LifecycleNoticeSessionEnded}, | 64 | {name: "server shutdown", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonServerShutdown}) }, notice: LifecycleNoticeSessionEnded}, | ||
| 66 | {name: "session deletion", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonSessionKilled}) }, notice: LifecycleNoticeSessionEnded}, | 65 | {name: "session deletion", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonSessionKilled}) }, notice: LifecycleNoticeSessionEnded}, | ||
| 67 | - | {name: "destination failure", settle: func(s *sessionTestStream) { | 66 | + | // A stream loss after attachment resumes the same session instead: |
| 68 | - | s.fail(ports.BrokerStreamLost{Connection: ports.BrokerConnectionID{1}, Stream: 1, Epoch: 3, Cause: domain.RemoteFailureTransport, Err: errors.New("private diagnostic")}) | 67 | + | // TestSupervisorAttachmentLossResumesSameSession pins that path. |
| 69 | - | }, notice: LifecycleNoticeDestinationFailed}, | |||
| 70 | } { | 68 | } { | ||
| 71 | t.Run(tc.name, func(t *testing.T) { | 69 | t.Run(tc.name, func(t *testing.T) { | ||
| 72 | picker := newAttachTestPicker() | 70 | picker := newAttachTestPicker() | ||
M internal/usecase/client/supervisor.go +11 -0
| @@ -442,6 +442,17 @@ type Supervisor struct { | |||
| 442 | 442 | // target while an attachment was live. It is only touched from the run | |
| 443 | 443 | // goroutine and consumed by runResolvedAttachment. | |
| 444 | 444 | pendingSwap *pickerAttachmentTarget | |
| 445 | + | // resume is the exact target a lost attachment reconnects to, set by | |
| 446 | + | // settleAttachment and consumed by runResolvedAttachment. It is only | |
| 447 | + | // touched from the run goroutine. | |
| 448 | + | resume *pickerAttachmentTarget | |
| 449 | + | // resuming is true while runResolvedAttachment reconnects a lost | |
| 450 | + | // attachment; transport-class failures of those attempts are recorded in | |
| 451 | + | // resumeErr instead of being presented, so a retry stays on Connecting. | |
| 452 | + | // resumeErr also carries the loss that started the resume. Both are only | |
| 453 | + | // touched from the run goroutine. | |
| 454 | + | resuming bool | |
| 455 | + | resumeErr error | |
| 445 | 456 | // kills runs the picker's `x` operations off the run goroutine. | |
| 446 | 457 | kills pickerKills | |
| 447 | 458 | // routes is the client route ledger published to the serving daemon; | |
| @@ -442,6 +442,17 @@ type Supervisor struct { | |||||
| 442 | // target while an attachment was live. It is only touched from the run | 442 | // target while an attachment was live. It is only touched from the run | ||
| 443 | // goroutine and consumed by runResolvedAttachment. | 443 | // goroutine and consumed by runResolvedAttachment. | ||
| 444 | pendingSwap *pickerAttachmentTarget | 444 | pendingSwap *pickerAttachmentTarget | ||
| 445 | + | // resume is the exact target a lost attachment reconnects to, set by | |||
| 446 | + | // settleAttachment and consumed by runResolvedAttachment. It is only | |||
| 447 | + | // touched from the run goroutine. | |||
| 448 | + | resume *pickerAttachmentTarget | |||
| 449 | + | // resuming is true while runResolvedAttachment reconnects a lost | |||
| 450 | + | // attachment; transport-class failures of those attempts are recorded in | |||
| 451 | + | // resumeErr instead of being presented, so a retry stays on Connecting. | |||
| 452 | + | // resumeErr also carries the loss that started the resume. Both are only | |||
| 453 | + | // touched from the run goroutine. | |||
| 454 | + | resuming bool | |||
| 455 | + | resumeErr error | |||
| 445 | // kills runs the picker's `x` operations off the run goroutine. | 456 | // kills runs the picker's `x` operations off the run goroutine. | ||
| 446 | kills pickerKills | 457 | kills pickerKills | ||
| 447 | // routes is the client route ledger published to the serving daemon; | 458 | // routes is the client route ledger published to the serving daemon; | ||
M internal/usecase/client/supervisor_attach.go +174 -2
| @@ -36,6 +36,10 @@ func attachmentTimeoutError(cause error) error { | |||
| 36 | 36 | return ports.BrokerError{Code: ports.BrokerErrorTimeout, Text: "attachment timed out", Cause: cause} | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | + | // errResumeForegroundBusy ends a resume whose reattach could not claim the | |
| 40 | + | // terminal foreground. | |
| 41 | + | var errResumeForegroundBusy = ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "resume could not claim the terminal"} | |
| 42 | + | ||
| 39 | 43 | // attachmentDeadline owns the one absolute deadline of a whole attachment. It | |
| 40 | 44 | // is safe for concurrent use: its timer goroutine expires it while the | |
| 41 | 45 | // supervisor's run goroutine may adopt a later deadline after the stream opens. | |
| @@ -550,11 +554,64 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||
| 550 | 554 | picker.SetOwnsInput(true) | |
| 551 | 555 | input.acquirePicker() | |
| 552 | 556 | }() | |
| 557 | + | defer func() { s.resuming, s.resumeErr, s.resume = false, nil, nil }() | |
| 558 | + | resumes := 0 | |
| 553 | 559 | for { | |
| 560 | + | s.resumeErr = nil | |
| 561 | + | attemptStart := s.cfg.Clock.Now() | |
| 554 | 562 | terminated, termErr, swap := s.attachOnce(ctx, input, service, target, localProvenance) | |
| 555 | - | if terminated || swap == nil { | |
| 563 | + | if terminated { | |
| 556 | 564 | return terminated, termErr | |
| 557 | 565 | } | |
| 566 | + | if swap == nil { | |
| 567 | + | resume := s.resume | |
| 568 | + | s.resume = nil | |
| 569 | + | switch { | |
| 570 | + | case resume != nil: | |
| 571 | + | // A live attachment was lost. One that stayed up long enough | |
| 572 | + | // starts a fresh resume budget; one that drops right after | |
| 573 | + | // attaching keeps counting, so a flapping session gives up. | |
| 574 | + | if s.cfg.Clock.Now().Sub(attemptStart) >= resumeStableAttachment { | |
| 575 | + | resumes = 0 | |
| 576 | + | } | |
| 577 | + | case s.resuming && s.resumeErr != nil: | |
| 578 | + | // A resume attempt hit a transport-class failure before | |
| 579 | + | // attaching (the route or destination is still down): try the | |
| 580 | + | // same target again. | |
| 581 | + | retry := target | |
| 582 | + | resume = &retry | |
| 583 | + | default: | |
| 584 | + | // No resume, or the attempt already presented a final outcome | |
| 585 | + | // (session gone, ended, refused). | |
| 586 | + | return false, nil | |
| 587 | + | } | |
| 588 | + | lastErr := s.resumeErr | |
| 589 | + | resumes++ | |
| 590 | + | if resumes > maxAttachmentResumes { | |
| 591 | + | s.logger.Warn("client_attachment_resume_exhausted", "endpoint", resume.request.Endpoint, "session", resume.request.Target.SessionName, "attempts", resumes-1, "error", lastErr) | |
| 592 | + | s.resuming = false | |
| 593 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: lastErr}) | |
| 594 | + | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | |
| 595 | + | s.notifyAttachment(lastErr) | |
| 596 | + | return false, nil | |
| 597 | + | } | |
| 598 | + | s.resuming = true | |
| 599 | + | if terminated, termErr, brokerLost := s.waitResume(ctx, input, service, resumes); terminated || brokerLost { | |
| 600 | + | return terminated, termErr | |
| 601 | + | } | |
| 602 | + | stream, err := service.NextStreamID() | |
| 603 | + | if err != nil { | |
| 604 | + | s.resuming = false | |
| 605 | + | s.reportAttachmentFailure(err) | |
| 606 | + | return false, nil | |
| 607 | + | } | |
| 608 | + | resume.request.Stream = stream | |
| 609 | + | s.logger.Info("client_attachment_resume", "endpoint", resume.request.Endpoint, "session", resume.request.Target.SessionName, "attempt", resumes) | |
| 610 | + | target = *resume | |
| 611 | + | continue | |
| 612 | + | } | |
| 613 | + | resumes = 0 | |
| 614 | + | s.resuming = false | |
| 558 | 615 | // A commit from the picker overlay to another target: the previous | |
| 559 | 616 | // attachment detached cleanly, and the swap goes through Connecting | |
| 560 | 617 | // exactly like any committed selection. The stream identity is | |
| @@ -642,6 +699,13 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||
| 642 | 699 | // Another foreground owns the terminal, or the shared reader is | |
| 643 | 700 | // claimed. Begin never closes the stream, so the supervisor does. | |
| 644 | 701 | _ = stream.Close() | |
| 702 | + | if s.resuming { | |
| 703 | + | // A resume must say why it stopped instead of dropping silently | |
| 704 | + | // to the picker. | |
| 705 | + | s.resuming = false | |
| 706 | + | s.reportAttachmentFailure(errResumeForegroundBusy) | |
| 707 | + | return false, nil | |
| 708 | + | } | |
| 645 | 709 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 646 | 710 | return false, nil | |
| 647 | 711 | } | |
| @@ -739,6 +803,9 @@ settlement: | |||
| 739 | 803 | } | |
| 740 | 804 | if deadline.TimedOut() && !result.adopted { | |
| 741 | 805 | timeout := attachmentTimeoutError(errAttachmentDeadline) | |
| 806 | + | if s.deferResumeFailure(timeout) { | |
| 807 | + | return false, nil | |
| 808 | + | } | |
| 742 | 809 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout}) | |
| 743 | 810 | s.notifyAttachment(timeout) | |
| 744 | 811 | return false, nil | |
| @@ -755,8 +822,25 @@ settlement: | |||
| 755 | 822 | s.notifyLifecycle(LifecycleNoticeDetachToPicker) | |
| 756 | 823 | return false, nil | |
| 757 | 824 | } | |
| 825 | + | if result.adopted && result.event.Kind == AttachmentEventLost && run.fg.Attached() { | |
| 826 | + | // The stream carrying a live attachment was lost while the broker stayed | |
| 827 | + | // up. The daemon parks the session for resume, so reconnect to the exact | |
| 828 | + | // session and tab that were showing instead of dropping to the picker. | |
| 829 | + | if resume, ok := resumeTarget(request, run.fg); ok { | |
| 830 | + | s.logger.Warn("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String(), "error", result.event.Err, "cause", errors.Unwrap(result.event.Err), "resume", true) | |
| 831 | + | s.resume = &resume | |
| 832 | + | s.resumeErr = result.event.Err | |
| 833 | + | return false, nil | |
| 834 | + | } | |
| 835 | + | } | |
| 758 | 836 | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | |
| 759 | 837 | s.logger.Warn("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String(), "error", result.event.Err, "cause", errors.Unwrap(result.event.Err)) | |
| 838 | + | // A resume attempt whose stream was lost before attaching is still a | |
| 839 | + | // transport failure: retry instead of presenting it. | |
| 840 | + | if result.event.Kind == AttachmentEventLost && s.resuming { | |
| 841 | + | s.resumeErr = result.event.Err | |
| 842 | + | return false, nil | |
| 843 | + | } | |
| 760 | 844 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | |
| 761 | 845 | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | |
| 762 | 846 | s.notifyAttachment(result.event.Err) | |
| @@ -774,8 +858,96 @@ settlement: | |||
| 774 | 858 | return false, nil | |
| 775 | 859 | } | |
| 776 | 860 | ||
| 777 | - | // reportAttachmentFailure returns to the picker with a typed, visible failure. | |
| 861 | + | // maxAttachmentResumes bounds how many times one lost attachment reconnects | |
| 862 | + | // before the client gives up and returns to the picker. With the supervisor | |
| 863 | + | // backoff this spans a few seconds, enough to ride out a transient drop. | |
| 864 | + | const maxAttachmentResumes = 5 | |
| 865 | + | ||
| 866 | + | // resumeStableAttachment is how long a resumed attachment must stay up before | |
| 867 | + | // its next loss gets a fresh resume budget. | |
| 868 | + | const resumeStableAttachment = 30 * time.Second | |
| 869 | + | ||
| 870 | + | // resumeTarget builds the exact reattach request for a lost attachment: the | |
| 871 | + | // session and tab it last committed, over the same local or remote route. A | |
| 872 | + | // foreground that never committed a session has nothing to resume. | |
| 873 | + | func resumeTarget(request ports.BrokerOpenStreamRequest, fg *attachmentForeground) (pickerAttachmentTarget, bool) { | |
| 874 | + | target, tab, known := fg.committedSnapshot() | |
| 875 | + | if !known || target.Validate() != nil { | |
| 876 | + | return pickerAttachmentTarget{}, false | |
| 877 | + | } | |
| 878 | + | resume := request | |
| 879 | + | resume.Admission = ports.BrokerAdmissionExact | |
| 880 | + | resume.Target = target | |
| 881 | + | resume.Name = "" | |
| 882 | + | return pickerAttachmentTarget{request: resume, tab: attachmentTab{preferred: tab}}, true | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | // waitResume presents Connecting and waits one backoff interval before the | |
| 886 | + | // next resume attempt. It settles early on cancellation or terminal EOF | |
| 887 | + | // (terminated), or on broker loss, which ends the resume and leaves the ready | |
| 888 | + | // loop to observe and retire the connection. | |
| 889 | + | func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, attempt int) (terminated bool, termErr error, brokerLost bool) { | |
| 890 | + | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | |
| 891 | + | delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter) | |
| 892 | + | timer := s.cfg.Clock.NewTimer(delay) | |
| 893 | + | defer stopSupervisorTimer(timer) | |
| 894 | + | for { | |
| 895 | + | select { | |
| 896 | + | case <-timer.C(): | |
| 897 | + | return false, nil, false | |
| 898 | + | case <-ctx.Done(): | |
| 899 | + | return true, ctx.Err(), false | |
| 900 | + | case err := <-input.EOF(): | |
| 901 | + | return true, terminalReadCause(err), false | |
| 902 | + | case <-service.Done(): | |
| 903 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 904 | + | return false, nil, true | |
| 905 | + | case <-s.presentationInvalidation(): | |
| 906 | + | s.renderResizeInvalidation() | |
| 907 | + | case <-s.spinnerTick(): | |
| 908 | + | s.cfg.Spinner.AdvanceSpinner(s.State()) | |
| 909 | + | } | |
| 910 | + | } | |
| 911 | + | } | |
| 912 | + | ||
| 913 | + | // deferResumeFailure records a transport-class failure of a resume attempt so | |
| 914 | + | // runResolvedAttachment retries it while Connecting stays up. Any other | |
| 915 | + | // failure, or a failure outside a resume, is presented by the caller. | |
| 916 | + | func (s *Supervisor) deferResumeFailure(err error) bool { | |
| 917 | + | if !s.resuming || !resumableFailure(err) { | |
| 918 | + | return false | |
| 919 | + | } | |
| 920 | + | s.logger.Info("client_attachment_resume_failed", "code", brokerErrorCode(err), "error", err, "cause", errors.Unwrap(err)) | |
| 921 | + | s.resumeErr = err | |
| 922 | + | return true | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | // resumableFailure reports whether a resume attempt failed for a transport | |
| 926 | + | // reason worth retrying. Refusals, protocol failures, and an ambiguous outcome | |
| 927 | + | // are final. | |
| 928 | + | func resumableFailure(err error) bool { | |
| 929 | + | var lost ports.BrokerStreamLost | |
| 930 | + | if errors.As(err, &lost) { | |
| 931 | + | return true | |
| 932 | + | } | |
| 933 | + | var typed ports.BrokerError | |
| 934 | + | if !errors.As(err, &typed) { | |
| 935 | + | return false | |
| 936 | + | } | |
| 937 | + | switch typed.Code { | |
| 938 | + | case ports.BrokerErrorUnavailable, ports.BrokerErrorTimeout, ports.BrokerErrorAttachmentLost: | |
| 939 | + | return true | |
| 940 | + | default: | |
| 941 | + | return false | |
| 942 | + | } | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | // reportAttachmentFailure returns to the picker with a typed, visible failure, | |
| 946 | + | // unless a resume attempt defers it for a retry. | |
| 778 | 947 | func (s *Supervisor) reportAttachmentFailure(err error) { | |
| 948 | + | if s.deferResumeFailure(err) { | |
| 949 | + | return | |
| 950 | + | } | |
| 779 | 951 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err}) | |
| 780 | 952 | // Classification order matters: a local refusal never dialed a destination, | |
| 781 | 953 | // so it must not be reported as one, and only a genuinely unknown outcome | |
| @@ -36,6 +36,10 @@ func attachmentTimeoutError(cause error) error { | |||||
| 36 | return ports.BrokerError{Code: ports.BrokerErrorTimeout, Text: "attachment timed out", Cause: cause} | 36 | return ports.BrokerError{Code: ports.BrokerErrorTimeout, Text: "attachment timed out", Cause: cause} | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | + | // errResumeForegroundBusy ends a resume whose reattach could not claim the | |||
| 40 | + | // terminal foreground. | |||
| 41 | + | var errResumeForegroundBusy = ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "resume could not claim the terminal"} | |||
| 42 | + | ||||
| 39 | // attachmentDeadline owns the one absolute deadline of a whole attachment. It | 43 | // attachmentDeadline owns the one absolute deadline of a whole attachment. It | ||
| 40 | // is safe for concurrent use: its timer goroutine expires it while the | 44 | // is safe for concurrent use: its timer goroutine expires it while the | ||
| 41 | // supervisor's run goroutine may adopt a later deadline after the stream opens. | 45 | // supervisor's run goroutine may adopt a later deadline after the stream opens. | ||
| @@ -550,11 +554,64 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||||
| 550 | picker.SetOwnsInput(true) | 554 | picker.SetOwnsInput(true) | ||
| 551 | input.acquirePicker() | 555 | input.acquirePicker() | ||
| 552 | }() | 556 | }() | ||
| 557 | + | defer func() { s.resuming, s.resumeErr, s.resume = false, nil, nil }() | |||
| 558 | + | resumes := 0 | |||
| 553 | for { | 559 | for { | ||
| 560 | + | s.resumeErr = nil | |||
| 561 | + | attemptStart := s.cfg.Clock.Now() | |||
| 554 | terminated, termErr, swap := s.attachOnce(ctx, input, service, target, localProvenance) | 562 | terminated, termErr, swap := s.attachOnce(ctx, input, service, target, localProvenance) | ||
| 555 | - | if terminated || swap == nil { | 563 | + | if terminated { |
| 556 | return terminated, termErr | 564 | return terminated, termErr | ||
| 557 | } | 565 | } | ||
| 566 | + | if swap == nil { | |||
| 567 | + | resume := s.resume | |||
| 568 | + | s.resume = nil | |||
| 569 | + | switch { | |||
| 570 | + | case resume != nil: | |||
| 571 | + | // A live attachment was lost. One that stayed up long enough | |||
| 572 | + | // starts a fresh resume budget; one that drops right after | |||
| 573 | + | // attaching keeps counting, so a flapping session gives up. | |||
| 574 | + | if s.cfg.Clock.Now().Sub(attemptStart) >= resumeStableAttachment { | |||
| 575 | + | resumes = 0 | |||
| 576 | + | } | |||
| 577 | + | case s.resuming && s.resumeErr != nil: | |||
| 578 | + | // A resume attempt hit a transport-class failure before | |||
| 579 | + | // attaching (the route or destination is still down): try the | |||
| 580 | + | // same target again. | |||
| 581 | + | retry := target | |||
| 582 | + | resume = &retry | |||
| 583 | + | default: | |||
| 584 | + | // No resume, or the attempt already presented a final outcome | |||
| 585 | + | // (session gone, ended, refused). | |||
| 586 | + | return false, nil | |||
| 587 | + | } | |||
| 588 | + | lastErr := s.resumeErr | |||
| 589 | + | resumes++ | |||
| 590 | + | if resumes > maxAttachmentResumes { | |||
| 591 | + | s.logger.Warn("client_attachment_resume_exhausted", "endpoint", resume.request.Endpoint, "session", resume.request.Target.SessionName, "attempts", resumes-1, "error", lastErr) | |||
| 592 | + | s.resuming = false | |||
| 593 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: lastErr}) | |||
| 594 | + | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | |||
| 595 | + | s.notifyAttachment(lastErr) | |||
| 596 | + | return false, nil | |||
| 597 | + | } | |||
| 598 | + | s.resuming = true | |||
| 599 | + | if terminated, termErr, brokerLost := s.waitResume(ctx, input, service, resumes); terminated || brokerLost { | |||
| 600 | + | return terminated, termErr | |||
| 601 | + | } | |||
| 602 | + | stream, err := service.NextStreamID() | |||
| 603 | + | if err != nil { | |||
| 604 | + | s.resuming = false | |||
| 605 | + | s.reportAttachmentFailure(err) | |||
| 606 | + | return false, nil | |||
| 607 | + | } | |||
| 608 | + | resume.request.Stream = stream | |||
| 609 | + | s.logger.Info("client_attachment_resume", "endpoint", resume.request.Endpoint, "session", resume.request.Target.SessionName, "attempt", resumes) | |||
| 610 | + | target = *resume | |||
| 611 | + | continue | |||
| 612 | + | } | |||
| 613 | + | resumes = 0 | |||
| 614 | + | s.resuming = false | |||
| 558 | // A commit from the picker overlay to another target: the previous | 615 | // A commit from the picker overlay to another target: the previous | ||
| 559 | // attachment detached cleanly, and the swap goes through Connecting | 616 | // attachment detached cleanly, and the swap goes through Connecting | ||
| 560 | // exactly like any committed selection. The stream identity is | 617 | // exactly like any committed selection. The stream identity is | ||
| @@ -642,6 +699,13 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||||
| 642 | // Another foreground owns the terminal, or the shared reader is | 699 | // Another foreground owns the terminal, or the shared reader is | ||
| 643 | // claimed. Begin never closes the stream, so the supervisor does. | 700 | // claimed. Begin never closes the stream, so the supervisor does. | ||
| 644 | _ = stream.Close() | 701 | _ = stream.Close() | ||
| 702 | + | if s.resuming { | |||
| 703 | + | // A resume must say why it stopped instead of dropping silently | |||
| 704 | + | // to the picker. | |||
| 705 | + | s.resuming = false | |||
| 706 | + | s.reportAttachmentFailure(errResumeForegroundBusy) | |||
| 707 | + | return false, nil | |||
| 708 | + | } | |||
| 645 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | 709 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | ||
| 646 | return false, nil | 710 | return false, nil | ||
| 647 | } | 711 | } | ||
| @@ -739,6 +803,9 @@ settlement: | |||||
| 739 | } | 803 | } | ||
| 740 | if deadline.TimedOut() && !result.adopted { | 804 | if deadline.TimedOut() && !result.adopted { | ||
| 741 | timeout := attachmentTimeoutError(errAttachmentDeadline) | 805 | timeout := attachmentTimeoutError(errAttachmentDeadline) | ||
| 806 | + | if s.deferResumeFailure(timeout) { | |||
| 807 | + | return false, nil | |||
| 808 | + | } | |||
| 742 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout}) | 809 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout}) | ||
| 743 | s.notifyAttachment(timeout) | 810 | s.notifyAttachment(timeout) | ||
| 744 | return false, nil | 811 | return false, nil | ||
| @@ -755,8 +822,25 @@ settlement: | |||||
| 755 | s.notifyLifecycle(LifecycleNoticeDetachToPicker) | 822 | s.notifyLifecycle(LifecycleNoticeDetachToPicker) | ||
| 756 | return false, nil | 823 | return false, nil | ||
| 757 | } | 824 | } | ||
| 825 | + | if result.adopted && result.event.Kind == AttachmentEventLost && run.fg.Attached() { | |||
| 826 | + | // The stream carrying a live attachment was lost while the broker stayed | |||
| 827 | + | // up. The daemon parks the session for resume, so reconnect to the exact | |||
| 828 | + | // session and tab that were showing instead of dropping to the picker. | |||
| 829 | + | if resume, ok := resumeTarget(request, run.fg); ok { | |||
| 830 | + | s.logger.Warn("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String(), "error", result.event.Err, "cause", errors.Unwrap(result.event.Err), "resume", true) | |||
| 831 | + | s.resume = &resume | |||
| 832 | + | s.resumeErr = result.event.Err | |||
| 833 | + | return false, nil | |||
| 834 | + | } | |||
| 835 | + | } | |||
| 758 | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | 836 | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | ||
| 759 | s.logger.Warn("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String(), "error", result.event.Err, "cause", errors.Unwrap(result.event.Err)) | 837 | s.logger.Warn("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String(), "error", result.event.Err, "cause", errors.Unwrap(result.event.Err)) | ||
| 838 | + | // A resume attempt whose stream was lost before attaching is still a | |||
| 839 | + | // transport failure: retry instead of presenting it. | |||
| 840 | + | if result.event.Kind == AttachmentEventLost && s.resuming { | |||
| 841 | + | s.resumeErr = result.event.Err | |||
| 842 | + | return false, nil | |||
| 843 | + | } | |||
| 760 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | 844 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | ||
| 761 | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | 845 | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | ||
| 762 | s.notifyAttachment(result.event.Err) | 846 | s.notifyAttachment(result.event.Err) | ||
| @@ -774,8 +858,96 @@ settlement: | |||||
| 774 | return false, nil | 858 | return false, nil | ||
| 775 | } | 859 | } | ||
| 776 | 860 | ||||
| 777 | - | // reportAttachmentFailure returns to the picker with a typed, visible failure. | 861 | + | // maxAttachmentResumes bounds how many times one lost attachment reconnects |
| 862 | + | // before the client gives up and returns to the picker. With the supervisor | |||
| 863 | + | // backoff this spans a few seconds, enough to ride out a transient drop. | |||
| 864 | + | const maxAttachmentResumes = 5 | |||
| 865 | + | ||||
| 866 | + | // resumeStableAttachment is how long a resumed attachment must stay up before | |||
| 867 | + | // its next loss gets a fresh resume budget. | |||
| 868 | + | const resumeStableAttachment = 30 * time.Second | |||
| 869 | + | ||||
| 870 | + | // resumeTarget builds the exact reattach request for a lost attachment: the | |||
| 871 | + | // session and tab it last committed, over the same local or remote route. A | |||
| 872 | + | // foreground that never committed a session has nothing to resume. | |||
| 873 | + | func resumeTarget(request ports.BrokerOpenStreamRequest, fg *attachmentForeground) (pickerAttachmentTarget, bool) { | |||
| 874 | + | target, tab, known := fg.committedSnapshot() | |||
| 875 | + | if !known || target.Validate() != nil { | |||
| 876 | + | return pickerAttachmentTarget{}, false | |||
| 877 | + | } | |||
| 878 | + | resume := request | |||
| 879 | + | resume.Admission = ports.BrokerAdmissionExact | |||
| 880 | + | resume.Target = target | |||
| 881 | + | resume.Name = "" | |||
| 882 | + | return pickerAttachmentTarget{request: resume, tab: attachmentTab{preferred: tab}}, true | |||
| 883 | + | } | |||
| 884 | + | ||||
| 885 | + | // waitResume presents Connecting and waits one backoff interval before the | |||
| 886 | + | // next resume attempt. It settles early on cancellation or terminal EOF | |||
| 887 | + | // (terminated), or on broker loss, which ends the resume and leaves the ready | |||
| 888 | + | // loop to observe and retire the connection. | |||
| 889 | + | func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, attempt int) (terminated bool, termErr error, brokerLost bool) { | |||
| 890 | + | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | |||
| 891 | + | delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter) | |||
| 892 | + | timer := s.cfg.Clock.NewTimer(delay) | |||
| 893 | + | defer stopSupervisorTimer(timer) | |||
| 894 | + | for { | |||
| 895 | + | select { | |||
| 896 | + | case <-timer.C(): | |||
| 897 | + | return false, nil, false | |||
| 898 | + | case <-ctx.Done(): | |||
| 899 | + | return true, ctx.Err(), false | |||
| 900 | + | case err := <-input.EOF(): | |||
| 901 | + | return true, terminalReadCause(err), false | |||
| 902 | + | case <-service.Done(): | |||
| 903 | + | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |||
| 904 | + | return false, nil, true | |||
| 905 | + | case <-s.presentationInvalidation(): | |||
| 906 | + | s.renderResizeInvalidation() | |||
| 907 | + | case <-s.spinnerTick(): | |||
| 908 | + | s.cfg.Spinner.AdvanceSpinner(s.State()) | |||
| 909 | + | } | |||
| 910 | + | } | |||
| 911 | + | } | |||
| 912 | + | ||||
| 913 | + | // deferResumeFailure records a transport-class failure of a resume attempt so | |||
| 914 | + | // runResolvedAttachment retries it while Connecting stays up. Any other | |||
| 915 | + | // failure, or a failure outside a resume, is presented by the caller. | |||
| 916 | + | func (s *Supervisor) deferResumeFailure(err error) bool { | |||
| 917 | + | if !s.resuming || !resumableFailure(err) { | |||
| 918 | + | return false | |||
| 919 | + | } | |||
| 920 | + | s.logger.Info("client_attachment_resume_failed", "code", brokerErrorCode(err), "error", err, "cause", errors.Unwrap(err)) | |||
| 921 | + | s.resumeErr = err | |||
| 922 | + | return true | |||
| 923 | + | } | |||
| 924 | + | ||||
| 925 | + | // resumableFailure reports whether a resume attempt failed for a transport | |||
| 926 | + | // reason worth retrying. Refusals, protocol failures, and an ambiguous outcome | |||
| 927 | + | // are final. | |||
| 928 | + | func resumableFailure(err error) bool { | |||
| 929 | + | var lost ports.BrokerStreamLost | |||
| 930 | + | if errors.As(err, &lost) { | |||
| 931 | + | return true | |||
| 932 | + | } | |||
| 933 | + | var typed ports.BrokerError | |||
| 934 | + | if !errors.As(err, &typed) { | |||
| 935 | + | return false | |||
| 936 | + | } | |||
| 937 | + | switch typed.Code { | |||
| 938 | + | case ports.BrokerErrorUnavailable, ports.BrokerErrorTimeout, ports.BrokerErrorAttachmentLost: | |||
| 939 | + | return true | |||
| 940 | + | default: | |||
| 941 | + | return false | |||
| 942 | + | } | |||
| 943 | + | } | |||
| 944 | + | ||||
| 945 | + | // reportAttachmentFailure returns to the picker with a typed, visible failure, | |||
| 946 | + | // unless a resume attempt defers it for a retry. | |||
| 778 | func (s *Supervisor) reportAttachmentFailure(err error) { | 947 | func (s *Supervisor) reportAttachmentFailure(err error) { | ||
| 948 | + | if s.deferResumeFailure(err) { | |||
| 949 | + | return | |||
| 950 | + | } | |||
| 779 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err}) | 951 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err}) | ||
| 780 | // Classification order matters: a local refusal never dialed a destination, | 952 | // Classification order matters: a local refusal never dialed a destination, | ||
| 781 | // so it must not be reported as one, and only a genuinely unknown outcome | 953 | // so it must not be reported as one, and only a genuinely unknown outcome | ||
M internal/usecase/client/supervisor_attach_test.go +151 -34
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "strings" | |
| 9 | 9 | "sync" | |
| 10 | + | "sync/atomic" | |
| 10 | 11 | "testing" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| @@ -1288,42 +1289,158 @@ func TestAttachmentDeadlineConcurrentExpiryAndRetirement(t *testing.T) { | |||
| 1288 | 1289 | } | |
| 1289 | 1290 | } | |
| 1290 | 1291 | ||
| 1291 | - | // TestSupervisorAttachmentPostAttachmentLossReturnsToPicker proves a stream | |
| 1292 | - | // that fails after attachment never keeps the attached presentation: the | |
| 1293 | - | // supervisor returns to the picker with the typed loss. | |
| 1294 | - | func TestSupervisorAttachmentPostAttachmentLossReturnsToPicker(t *testing.T) { | |
| 1295 | - | picker := newAttachTestPicker() | |
| 1296 | - | var ( | |
| 1297 | - | streamMu sync.Mutex | |
| 1298 | - | stream *sessionTestStream | |
| 1299 | - | ) | |
| 1300 | - | harness := startAttachHarness(t, picker) | |
| 1301 | - | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 1302 | - | admitted := newSessionTestStream() | |
| 1303 | - | streamMu.Lock() | |
| 1304 | - | stream = admitted | |
| 1305 | - | streamMu.Unlock() | |
| 1306 | - | return admitted, nil | |
| 1307 | - | }) | |
| 1308 | - | picker.commit(sessionTestRequest(true)) | |
| 1309 | - | require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |
| 1292 | + | // testStreamLoss is a physical loss of the stream carrying an attachment. | |
| 1293 | + | func testStreamLoss() ports.BrokerStreamLost { | |
| 1294 | + | return ports.BrokerStreamLost{Connection: ports.BrokerConnectionID{1}, Stream: 1, Epoch: 3, Cause: domain.RemoteFailureTransport, Err: errors.New("attachment lost")} | |
| 1295 | + | } | |
| 1310 | 1296 | ||
| 1311 | - | streamMu.Lock() | |
| 1312 | - | admitted := stream | |
| 1313 | - | streamMu.Unlock() | |
| 1314 | - | deliverReadyStream(t, admitted) | |
| 1315 | - | awaitAttachedState(t, harness.sup) | |
| 1297 | + | // resumeTestJitter is the backoff jitter of the resume tests. It keeps every | |
| 1298 | + | // resume delay distinct from the other timers sharing the fake clock (the | |
| 1299 | + | // 15s attachment deadline and the 200ms palette deadline). | |
| 1300 | + | func resumeTestJitter() float64 { return 0.5 } | |
| 1316 | 1301 | ||
| 1317 | - | admitted.fail(ports.BrokerStreamLost{ | |
| 1318 | - | Connection: ports.BrokerConnectionID{1}, | |
| 1319 | - | Stream: 1, | |
| 1320 | - | Epoch: 3, | |
| 1321 | - | Cause: domain.RemoteFailureTransport, | |
| 1322 | - | Err: errors.New("attachment lost"), | |
| 1323 | - | }) | |
| 1324 | - | awaitPickerState(t, harness.sup) | |
| 1325 | - | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | |
| 1326 | - | require.NotEqual(t, PresentAttached, harness.sup.State().Presentation, "a failed stream is never still attached") | |
| 1302 | + | // fireResumeTimer fires the resume backoff timer for one attempt. Other timers | |
| 1303 | + | // share the clock, so it matches the exact backoff delay. | |
| 1304 | + | func fireResumeTimer(t *testing.T, clock *supervisorTestClock, attempt int) { | |
| 1305 | + | t.Helper() | |
| 1306 | + | want := supervisorBackoffDelay(uint64(attempt), resumeTestJitter) | |
| 1307 | + | for { | |
| 1308 | + | timer := clock.awaitTimer(t) | |
| 1309 | + | if timer.delay == want { | |
| 1310 | + | timer.fire() | |
| 1311 | + | return | |
| 1312 | + | } | |
| 1313 | + | } | |
| 1314 | + | } | |
| 1315 | + | ||
| 1316 | + | // TestSupervisorAttachmentLossResumesSameSession is the regression for a | |
| 1317 | + | // remote attachment that died with attachment_lost and left the client stuck: | |
| 1318 | + | // a stream lost after attachment reconnects to the exact session it was | |
| 1319 | + | // showing, retries only transport-class failures, and returns to the picker | |
| 1320 | + | // with a single notice when the resume gives up. | |
| 1321 | + | func TestSupervisorAttachmentLossResumesSameSession(t *testing.T) { | |
| 1322 | + | type step uint8 | |
| 1323 | + | const ( | |
| 1324 | + | // attachThenLose attaches the resumed stream, stays up long enough to | |
| 1325 | + | // count as stable, then loses it again. | |
| 1326 | + | attachThenLose step = iota + 1 | |
| 1327 | + | // attachThenFlap attaches the resumed stream and loses it right away. | |
| 1328 | + | attachThenFlap | |
| 1329 | + | // attachAndStay attaches the resumed stream and keeps it. | |
| 1330 | + | attachAndStay | |
| 1331 | + | // loseBeforeAttach loses the resumed stream before it attaches. | |
| 1332 | + | loseBeforeAttach | |
| 1333 | + | // refuse fails the resumed open with a non-transport refusal. | |
| 1334 | + | refuse | |
| 1335 | + | // cancelDuringBackoff ends the client while the backoff runs. | |
| 1336 | + | cancelDuringBackoff | |
| 1337 | + | ) | |
| 1338 | + | repeat := func(s step, n int) []step { | |
| 1339 | + | steps := make([]step, n) | |
| 1340 | + | for i := range steps { | |
| 1341 | + | steps[i] = s | |
| 1342 | + | } | |
| 1343 | + | return steps | |
| 1344 | + | } | |
| 1345 | + | cases := []struct { | |
| 1346 | + | name string | |
| 1347 | + | steps []step | |
| 1348 | + | // wantPicker is the final outcome: back on the picker with exactly one | |
| 1349 | + | // DestinationFailed notice, or still attached with none. | |
| 1350 | + | wantPicker bool | |
| 1351 | + | }{ | |
| 1352 | + | {name: "resume attaches again", steps: []step{attachAndStay}}, | |
| 1353 | + | {name: "every stable resume restores the budget", steps: append(repeat(attachThenLose, maxAttachmentResumes+1), attachAndStay)}, | |
| 1354 | + | {name: "a flapping session gives up", steps: repeat(attachThenFlap, maxAttachmentResumes), wantPicker: true}, | |
| 1355 | + | {name: "transient failures then attach", steps: append(repeat(loseBeforeAttach, maxAttachmentResumes-1), attachAndStay)}, | |
| 1356 | + | {name: "exhausted returns to picker", steps: repeat(loseBeforeAttach, maxAttachmentResumes), wantPicker: true}, | |
| 1357 | + | {name: "refusal is final", steps: []step{refuse}, wantPicker: true}, | |
| 1358 | + | {name: "cancel during backoff never resumes", steps: []step{cancelDuringBackoff}}, | |
| 1359 | + | } | |
| 1360 | + | for _, tc := range cases { | |
| 1361 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1362 | + | picker := newAttachTestPicker() | |
| 1363 | + | notices := make(chan LifecycleNotice, 64) | |
| 1364 | + | harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { | |
| 1365 | + | cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } | |
| 1366 | + | cfg.Jitter = resumeTestJitter | |
| 1367 | + | }) | |
| 1368 | + | streams := make(chan *sessionTestStream, len(tc.steps)+2) | |
| 1369 | + | var refusing atomic.Bool | |
| 1370 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 1371 | + | if refusing.Load() { | |
| 1372 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "session gone"} | |
| 1373 | + | } | |
| 1374 | + | stream := newSessionTestStream() | |
| 1375 | + | streams <- stream | |
| 1376 | + | return stream, nil | |
| 1377 | + | }) | |
| 1378 | + | picker.commit(sessionTestRequest(false)) | |
| 1379 | + | first := <-streams | |
| 1380 | + | deliverReadyStream(t, first) | |
| 1381 | + | awaitAttachedState(t, harness.sup) | |
| 1382 | + | first.fail(testStreamLoss()) | |
| 1383 | + | ||
| 1384 | + | attempt := 1 | |
| 1385 | + | for _, s := range tc.steps { | |
| 1386 | + | if s == cancelDuringBackoff { | |
| 1387 | + | backoff := supervisorBackoffDelay(1, resumeTestJitter) | |
| 1388 | + | for harness.clock.awaitTimer(t).delay != backoff { | |
| 1389 | + | } | |
| 1390 | + | harness.cancel() | |
| 1391 | + | <-harness.runDone | |
| 1392 | + | require.Len(t, harness.service.openedRequests(), 1, "no resume after cancellation") | |
| 1393 | + | return | |
| 1394 | + | } | |
| 1395 | + | refusing.Store(s == refuse) | |
| 1396 | + | fireResumeTimer(t, harness.clock, attempt) | |
| 1397 | + | if s == refuse { | |
| 1398 | + | break | |
| 1399 | + | } | |
| 1400 | + | resumed := <-streams | |
| 1401 | + | if s == loseBeforeAttach { | |
| 1402 | + | resumed.fail(testStreamLoss()) | |
| 1403 | + | attempt++ | |
| 1404 | + | continue | |
| 1405 | + | } | |
| 1406 | + | require.Eventually(t, func() bool { return len(resumed.messages()) > 0 }, 5*time.Second, time.Millisecond) | |
| 1407 | + | deliverReadyStream(t, resumed) | |
| 1408 | + | awaitAttachedState(t, harness.sup) | |
| 1409 | + | switch s { | |
| 1410 | + | case attachThenLose: | |
| 1411 | + | advancePickerClock(harness.clock, resumeStableAttachment) | |
| 1412 | + | resumed.fail(testStreamLoss()) | |
| 1413 | + | attempt = 1 | |
| 1414 | + | case attachThenFlap: | |
| 1415 | + | resumed.fail(testStreamLoss()) | |
| 1416 | + | attempt++ | |
| 1417 | + | } | |
| 1418 | + | } | |
| 1419 | + | ||
| 1420 | + | requests := harness.service.openedRequests() | |
| 1421 | + | for _, resume := range requests[1:] { | |
| 1422 | + | require.Equal(t, ports.BrokerAdmissionExact, resume.Admission) | |
| 1423 | + | require.Equal(t, sessionTestOutput(1, "").Context.Route.Target, resume.Target, "resume names the committed session") | |
| 1424 | + | require.Equal(t, requests[0].Endpoint, resume.Endpoint) | |
| 1425 | + | require.NotEqual(t, requests[0].Stream, resume.Stream, "resume uses a fresh stream identity") | |
| 1426 | + | } | |
| 1427 | + | if !tc.wantPicker { | |
| 1428 | + | awaitAttachedState(t, harness.sup) | |
| 1429 | + | require.False(t, picker.owns(), "the picker never took the terminal") | |
| 1430 | + | require.Empty(t, notices, "a resume that attaches reports nothing") | |
| 1431 | + | return | |
| 1432 | + | } | |
| 1433 | + | awaitPickerState(t, harness.sup) | |
| 1434 | + | require.Eventually(t, picker.owns, 5*time.Second, time.Millisecond) | |
| 1435 | + | failed := 0 | |
| 1436 | + | for len(notices) > 0 { | |
| 1437 | + | if (<-notices).Kind == LifecycleNoticeDestinationFailed { | |
| 1438 | + | failed++ | |
| 1439 | + | } | |
| 1440 | + | } | |
| 1441 | + | require.Equal(t, 1, failed, "the resume reports its failure once") | |
| 1442 | + | }) | |
| 1443 | + | } | |
| 1327 | 1444 | } | |
| 1328 | 1445 | ||
| 1329 | 1446 | // TestSupervisorAttachmentCancellationRetiresGeneration proves cancellation | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "io" | 7 | "io" | ||
| 8 | "strings" | 8 | "strings" | ||
| 9 | "sync" | 9 | "sync" | ||
| 10 | + | "sync/atomic" | |||
| 10 | "testing" | 11 | "testing" | ||
| 11 | "time" | 12 | "time" | ||
| 12 | 13 | ||||
| @@ -1288,42 +1289,158 @@ func TestAttachmentDeadlineConcurrentExpiryAndRetirement(t *testing.T) { | |||||
| 1288 | } | 1289 | } | ||
| 1289 | } | 1290 | } | ||
| 1290 | 1291 | ||||
| 1291 | - | // TestSupervisorAttachmentPostAttachmentLossReturnsToPicker proves a stream | 1292 | + | // testStreamLoss is a physical loss of the stream carrying an attachment. |
| 1292 | - | // that fails after attachment never keeps the attached presentation: the | 1293 | + | func testStreamLoss() ports.BrokerStreamLost { |
| 1293 | - | // supervisor returns to the picker with the typed loss. | 1294 | + | return ports.BrokerStreamLost{Connection: ports.BrokerConnectionID{1}, Stream: 1, Epoch: 3, Cause: domain.RemoteFailureTransport, Err: errors.New("attachment lost")} |
| 1294 | - | func TestSupervisorAttachmentPostAttachmentLossReturnsToPicker(t *testing.T) { | 1295 | + | } |
| 1295 | - | picker := newAttachTestPicker() | |||
| 1296 | - | var ( | |||
| 1297 | - | streamMu sync.Mutex | |||
| 1298 | - | stream *sessionTestStream | |||
| 1299 | - | ) | |||
| 1300 | - | harness := startAttachHarness(t, picker) | |||
| 1301 | - | harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 1302 | - | admitted := newSessionTestStream() | |||
| 1303 | - | streamMu.Lock() | |||
| 1304 | - | stream = admitted | |||
| 1305 | - | streamMu.Unlock() | |||
| 1306 | - | return admitted, nil | |||
| 1307 | - | }) | |||
| 1308 | - | picker.commit(sessionTestRequest(true)) | |||
| 1309 | - | require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond) | |||
| 1310 | 1296 | ||||
| 1311 | - | streamMu.Lock() | 1297 | + | // resumeTestJitter is the backoff jitter of the resume tests. It keeps every |
| 1312 | - | admitted := stream | 1298 | + | // resume delay distinct from the other timers sharing the fake clock (the |
| 1313 | - | streamMu.Unlock() | 1299 | + | // 15s attachment deadline and the 200ms palette deadline). |
| 1314 | - | deliverReadyStream(t, admitted) | 1300 | + | func resumeTestJitter() float64 { return 0.5 } |
| 1315 | - | awaitAttachedState(t, harness.sup) | |||
| 1316 | 1301 | ||||
| 1317 | - | admitted.fail(ports.BrokerStreamLost{ | 1302 | + | // fireResumeTimer fires the resume backoff timer for one attempt. Other timers |
| 1318 | - | Connection: ports.BrokerConnectionID{1}, | 1303 | + | // share the clock, so it matches the exact backoff delay. |
| 1319 | - | Stream: 1, | 1304 | + | func fireResumeTimer(t *testing.T, clock *supervisorTestClock, attempt int) { |
| 1320 | - | Epoch: 3, | 1305 | + | t.Helper() |
| 1321 | - | Cause: domain.RemoteFailureTransport, | 1306 | + | want := supervisorBackoffDelay(uint64(attempt), resumeTestJitter) |
| 1322 | - | Err: errors.New("attachment lost"), | 1307 | + | for { |
| 1323 | - | }) | 1308 | + | timer := clock.awaitTimer(t) |
| 1324 | - | awaitPickerState(t, harness.sup) | 1309 | + | if timer.delay == want { |
| 1325 | - | require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond) | 1310 | + | timer.fire() |
| 1326 | - | require.NotEqual(t, PresentAttached, harness.sup.State().Presentation, "a failed stream is never still attached") | 1311 | + | return |
| 1312 | + | } | |||
| 1313 | + | } | |||
| 1314 | + | } | |||
| 1315 | + | ||||
| 1316 | + | // TestSupervisorAttachmentLossResumesSameSession is the regression for a | |||
| 1317 | + | // remote attachment that died with attachment_lost and left the client stuck: | |||
| 1318 | + | // a stream lost after attachment reconnects to the exact session it was | |||
| 1319 | + | // showing, retries only transport-class failures, and returns to the picker | |||
| 1320 | + | // with a single notice when the resume gives up. | |||
| 1321 | + | func TestSupervisorAttachmentLossResumesSameSession(t *testing.T) { | |||
| 1322 | + | type step uint8 | |||
| 1323 | + | const ( | |||
| 1324 | + | // attachThenLose attaches the resumed stream, stays up long enough to | |||
| 1325 | + | // count as stable, then loses it again. | |||
| 1326 | + | attachThenLose step = iota + 1 | |||
| 1327 | + | // attachThenFlap attaches the resumed stream and loses it right away. | |||
| 1328 | + | attachThenFlap | |||
| 1329 | + | // attachAndStay attaches the resumed stream and keeps it. | |||
| 1330 | + | attachAndStay | |||
| 1331 | + | // loseBeforeAttach loses the resumed stream before it attaches. | |||
| 1332 | + | loseBeforeAttach | |||
| 1333 | + | // refuse fails the resumed open with a non-transport refusal. | |||
| 1334 | + | refuse | |||
| 1335 | + | // cancelDuringBackoff ends the client while the backoff runs. | |||
| 1336 | + | cancelDuringBackoff | |||
| 1337 | + | ) | |||
| 1338 | + | repeat := func(s step, n int) []step { | |||
| 1339 | + | steps := make([]step, n) | |||
| 1340 | + | for i := range steps { | |||
| 1341 | + | steps[i] = s | |||
| 1342 | + | } | |||
| 1343 | + | return steps | |||
| 1344 | + | } | |||
| 1345 | + | cases := []struct { | |||
| 1346 | + | name string | |||
| 1347 | + | steps []step | |||
| 1348 | + | // wantPicker is the final outcome: back on the picker with exactly one | |||
| 1349 | + | // DestinationFailed notice, or still attached with none. | |||
| 1350 | + | wantPicker bool | |||
| 1351 | + | }{ | |||
| 1352 | + | {name: "resume attaches again", steps: []step{attachAndStay}}, | |||
| 1353 | + | {name: "every stable resume restores the budget", steps: append(repeat(attachThenLose, maxAttachmentResumes+1), attachAndStay)}, | |||
| 1354 | + | {name: "a flapping session gives up", steps: repeat(attachThenFlap, maxAttachmentResumes), wantPicker: true}, | |||
| 1355 | + | {name: "transient failures then attach", steps: append(repeat(loseBeforeAttach, maxAttachmentResumes-1), attachAndStay)}, | |||
| 1356 | + | {name: "exhausted returns to picker", steps: repeat(loseBeforeAttach, maxAttachmentResumes), wantPicker: true}, | |||
| 1357 | + | {name: "refusal is final", steps: []step{refuse}, wantPicker: true}, | |||
| 1358 | + | {name: "cancel during backoff never resumes", steps: []step{cancelDuringBackoff}}, | |||
| 1359 | + | } | |||
| 1360 | + | for _, tc := range cases { | |||
| 1361 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1362 | + | picker := newAttachTestPicker() | |||
| 1363 | + | notices := make(chan LifecycleNotice, 64) | |||
| 1364 | + | harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { | |||
| 1365 | + | cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } | |||
| 1366 | + | cfg.Jitter = resumeTestJitter | |||
| 1367 | + | }) | |||
| 1368 | + | streams := make(chan *sessionTestStream, len(tc.steps)+2) | |||
| 1369 | + | var refusing atomic.Bool | |||
| 1370 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 1371 | + | if refusing.Load() { | |||
| 1372 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "session gone"} | |||
| 1373 | + | } | |||
| 1374 | + | stream := newSessionTestStream() | |||
| 1375 | + | streams <- stream | |||
| 1376 | + | return stream, nil | |||
| 1377 | + | }) | |||
| 1378 | + | picker.commit(sessionTestRequest(false)) | |||
| 1379 | + | first := <-streams | |||
| 1380 | + | deliverReadyStream(t, first) | |||
| 1381 | + | awaitAttachedState(t, harness.sup) | |||
| 1382 | + | first.fail(testStreamLoss()) | |||
| 1383 | + | ||||
| 1384 | + | attempt := 1 | |||
| 1385 | + | for _, s := range tc.steps { | |||
| 1386 | + | if s == cancelDuringBackoff { | |||
| 1387 | + | backoff := supervisorBackoffDelay(1, resumeTestJitter) | |||
| 1388 | + | for harness.clock.awaitTimer(t).delay != backoff { | |||
| 1389 | + | } | |||
| 1390 | + | harness.cancel() | |||
| 1391 | + | <-harness.runDone | |||
| 1392 | + | require.Len(t, harness.service.openedRequests(), 1, "no resume after cancellation") | |||
| 1393 | + | return | |||
| 1394 | + | } | |||
| 1395 | + | refusing.Store(s == refuse) | |||
| 1396 | + | fireResumeTimer(t, harness.clock, attempt) | |||
| 1397 | + | if s == refuse { | |||
| 1398 | + | break | |||
| 1399 | + | } | |||
| 1400 | + | resumed := <-streams | |||
| 1401 | + | if s == loseBeforeAttach { | |||
| 1402 | + | resumed.fail(testStreamLoss()) | |||
| 1403 | + | attempt++ | |||
| 1404 | + | continue | |||
| 1405 | + | } | |||
| 1406 | + | require.Eventually(t, func() bool { return len(resumed.messages()) > 0 }, 5*time.Second, time.Millisecond) | |||
| 1407 | + | deliverReadyStream(t, resumed) | |||
| 1408 | + | awaitAttachedState(t, harness.sup) | |||
| 1409 | + | switch s { | |||
| 1410 | + | case attachThenLose: | |||
| 1411 | + | advancePickerClock(harness.clock, resumeStableAttachment) | |||
| 1412 | + | resumed.fail(testStreamLoss()) | |||
| 1413 | + | attempt = 1 | |||
| 1414 | + | case attachThenFlap: | |||
| 1415 | + | resumed.fail(testStreamLoss()) | |||
| 1416 | + | attempt++ | |||
| 1417 | + | } | |||
| 1418 | + | } | |||
| 1419 | + | ||||
| 1420 | + | requests := harness.service.openedRequests() | |||
| 1421 | + | for _, resume := range requests[1:] { | |||
| 1422 | + | require.Equal(t, ports.BrokerAdmissionExact, resume.Admission) | |||
| 1423 | + | require.Equal(t, sessionTestOutput(1, "").Context.Route.Target, resume.Target, "resume names the committed session") | |||
| 1424 | + | require.Equal(t, requests[0].Endpoint, resume.Endpoint) | |||
| 1425 | + | require.NotEqual(t, requests[0].Stream, resume.Stream, "resume uses a fresh stream identity") | |||
| 1426 | + | } | |||
| 1427 | + | if !tc.wantPicker { | |||
| 1428 | + | awaitAttachedState(t, harness.sup) | |||
| 1429 | + | require.False(t, picker.owns(), "the picker never took the terminal") | |||
| 1430 | + | require.Empty(t, notices, "a resume that attaches reports nothing") | |||
| 1431 | + | return | |||
| 1432 | + | } | |||
| 1433 | + | awaitPickerState(t, harness.sup) | |||
| 1434 | + | require.Eventually(t, picker.owns, 5*time.Second, time.Millisecond) | |||
| 1435 | + | failed := 0 | |||
| 1436 | + | for len(notices) > 0 { | |||
| 1437 | + | if (<-notices).Kind == LifecycleNoticeDestinationFailed { | |||
| 1438 | + | failed++ | |||
| 1439 | + | } | |||
| 1440 | + | } | |||
| 1441 | + | require.Equal(t, 1, failed, "the resume reports its failure once") | |||
| 1442 | + | }) | |||
| 1443 | + | } | |||
| 1327 | } | 1444 | } | ||
| 1328 | 1445 | ||||
| 1329 | // TestSupervisorAttachmentCancellationRetiresGeneration proves cancellation | 1446 | // TestSupervisorAttachmentCancellationRetiresGeneration proves cancellation | ||
M internal/usecase/daemon/client_frame_routing.go +9 -0
| @@ -2,6 +2,8 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "io" | |
| 6 | + | "net" | |
| 5 | 7 | "runtime" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/vev/internal/domain" | |
| @@ -63,6 +65,13 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 63 | 65 | ) | |
| 64 | 66 | continue | |
| 65 | 67 | } | |
| 68 | + | // A transport the daemon already swapped out or closed itself is | |
| 69 | + | // ordinary teardown, not a lost connection. | |
| 70 | + | if !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) && ac.currentTransportIs(tr) { | |
| 71 | + | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 72 | + | d.log.Warn("client connection lost", "session", sess.nameSnapshot(), "err", err) | |
| 73 | + | } | |
| 74 | + | } | |
| 66 | 75 | for range connectionSnapshotAttempts { | |
| 67 | 76 | sess, _, ok := d.currentAttachmentConnection(ac, tr) | |
| 68 | 77 | if !ok { | |
| @@ -2,6 +2,8 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "io" | |||
| 6 | + | "net" | |||
| 5 | "runtime" | 7 | "runtime" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| @@ -63,6 +65,13 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 63 | ) | 65 | ) | ||
| 64 | continue | 66 | continue | ||
| 65 | } | 67 | } | ||
| 68 | + | // A transport the daemon already swapped out or closed itself is | |||
| 69 | + | // ordinary teardown, not a lost connection. | |||
| 70 | + | if !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) && ac.currentTransportIs(tr) { | |||
| 71 | + | if sess := ac.currentAttachmentSession(); sess != nil { | |||
| 72 | + | d.log.Warn("client connection lost", "session", sess.nameSnapshot(), "err", err) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 66 | for range connectionSnapshotAttempts { | 75 | for range connectionSnapshotAttempts { | ||
| 67 | sess, _, ok := d.currentAttachmentConnection(ac, tr) | 76 | sess, _, ok := d.currentAttachmentConnection(ac, tr) | ||
| 68 | if !ok { | 77 | if !ok { | ||
M internal/usecase/daemon/render_test.go +4 -1
| @@ -619,7 +619,10 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 619 | 619 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 620 | 620 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 621 | 621 | ac.initOverlays() | |
| 622 | - | sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} | |
| 622 | + | // A lifecycle ID keeps the first paint valid. Without it the paint fails | |
| 623 | + | // validation, which the daemon treats as a send error and detaches the | |
| 624 | + | // attachment in the background, racing the second resize. | |
| 625 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} | |
| 623 | 626 | ac.setSession(sess) | |
| 624 | 627 | ||
| 625 | 628 | // Client rows are one more than the equivalent case in a single-bar | |
| @@ -619,7 +619,10 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||||
| 619 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | 619 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 620 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 620 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 621 | ac.initOverlays() | 621 | ac.initOverlays() | ||
| 622 | - | sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} | 622 | + | // A lifecycle ID keeps the first paint valid. Without it the paint fails |
| 623 | + | // validation, which the daemon treats as a send error and detaches the | |||
| 624 | + | // attachment in the background, racing the second resize. | |||
| 625 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} | |||
| 623 | ac.setSession(sess) | 626 | ac.setSession(sess) | ||
| 624 | 627 | ||||
| 625 | // Client rows are one more than the equivalent case in a single-bar | 628 | // Client rows are one more than the equivalent case in a single-bar | ||