detached 6253aa4

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 UnverifiedB5690EEEBB952194 The 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`,
119119 `_broker-mux-quic-bootstrap`, and `_broker-mux-quic-proxy` bridge a remote
120120 daemonmux socket to stdio or to one freshly authenticated QUIC stream.
121121
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+
122132 Setup is fully bounded: the dialer's one context/deadline covers the Unix
123133 dial, preamble, Register send, and wait for Registered, and the server requires Register within
124134 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 remote119 `_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 Unix132 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 within133 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 {
139139 }
140140
141141 // 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.
144147 func rejectSymlinkedComponents(abs string) error {
145148 volume := filepath.VolumeName(abs)
146149 rest := strings.TrimPrefix(abs, volume)
@@ -161,13 +164,18 @@ func rejectSymlinkedComponents(abs string) error {
161164 }
162165 return fmt.Errorf("brokerconfig: inspect %q: %w", current, err)
163166 }
164- if info.Mode()&os.ModeSymlink != 0 {
167+ if info.Mode()&os.ModeSymlink != 0 && !ownedByRoot(info) {
165168 return fmt.Errorf("brokerconfig: %q is a symlink", current)
166169 }
167170 }
168171 return nil
169172 }
170173
174+func ownedByRoot(info fs.FileInfo) bool {
175+ stat, ok := info.Sys().(*syscall.Stat_t)
176+ return ok && stat.Uid == 0
177+}
178+
171179 // overlaps reports whether two absolute cleaned paths are equal or one contains
172180 // the other.
173181 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 is141 // rejectSymlinkedComponents refuses a path any of whose existing components is
142-// a symlink. A component that does not exist yet ends the walk: the caller142+// 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 nil171 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 contains179 // 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) {
748748 resolver.local.Identity = "changed"
749749 require.Equal(t, original.local.Identity, config.Resolver().local.Identity)
750750 }
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
33 import (
44 "context"
55 "errors"
6+ "fmt"
7+ "strings"
68
79 "github.com/bnema/vev/internal/adapters/brokerwire"
810 "github.com/bnema/vev/internal/ports"
@@ -94,12 +96,25 @@ func admissionError(err error) error {
9496 }
9597
9698 // 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.
99104 func errorDetail(err error) brokerwire.ErrorDetail {
100105 if err == nil {
101106 return brokerwire.ErrorDetail{}
102107 }
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+ }
103118 var failure ports.BrokerError
104119 if errors.As(err, &failure) {
105120 return brokerwire.ErrorDetail{Code: failure.Code, Text: failure.Text}
@@ -138,9 +153,9 @@ func errorDetail(err error) brokerwire.ErrorDetail {
138153
139154 func boundedBrokerErrorText(err error) string {
140155 const max = 256
141- text := err.Error()
156+ text := ports.SanitizeBrokerDisplayText(err.Error())
142157 if len(text) > max {
143- text = text[:max]
158+ text = strings.ToValidUTF8(text[:max], "")
144159 }
145160 return text
146161 }
@@ -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-safe98 // errorDetail converts one broker failure into the bounded, presentation-safe
97-// wire detail. The diagnostic cause stays local: only the typed code, bounded99+// 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.BrokerError118 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 = 256155 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 text160 return text
146 }161 }
M internal/adapters/brokeripc/limits_test.go +40 -0
@@ -3,9 +3,11 @@ package brokeripc
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "io"
78 "io/fs"
89 "net"
10+ "os"
911 "sync"
1012 "syscall"
1113 "testing"
@@ -14,6 +16,8 @@ import (
1416 "github.com/stretchr/testify/require"
1517
1618 "github.com/bnema/vev/internal/adapters/brokerwire"
19+ "github.com/bnema/vev/internal/adapters/streamframe"
20+ "github.com/bnema/vev/internal/domain"
1721 "github.com/bnema/vev/internal/ports"
1822 "github.com/bnema/vev/internal/protocol/catalogue"
1923 "github.com/bnema/vev/internal/protocol/wire"
@@ -260,6 +264,40 @@ func TestErrorDetailMapping(t *testing.T) {
260264 require.ErrorIs(t, scoped, ports.BrokerAdmissionLimit)
261265 }
262266
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+
263301 // TestAdmissionErrorMapping proves connection-tracker refusals map onto the
264302 // closed ports admission taxonomy.
265303 func TestAdmissionErrorMapping(t *testing.T) {
@@ -361,8 +399,10 @@ func TestOrderlyDisconnectClassification(t *testing.T) {
361399 require.True(t, orderlyDisconnect(ErrConnectionClosed))
362400 require.True(t, orderlyDisconnect(errors.Join(ErrRegistrationTimeout, context.DeadlineExceeded)))
363401 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)}))
364403 require.True(t, orderlyDisconnect(net.ErrClosed))
365404 require.True(t, orderlyDisconnect(fs.ErrClosed))
405+ require.True(t, orderlyDisconnect(fmt.Errorf("write: %w", streamframe.ErrClosed)))
366406 require.False(t, orderlyDisconnect(ErrMalformedFrame))
367407 require.False(t, orderlyDisconnect(ErrProtocol))
368408 }
@@ -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 the301 // 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 (
1414
1515 "github.com/bnema/vev/internal/adapters/brokerwire"
1616 "github.com/bnema/vev/internal/adapters/ipc"
17+ "github.com/bnema/vev/internal/adapters/streamframe"
1718 "github.com/bnema/vev/internal/domain"
1819 "github.com/bnema/vev/internal/ports"
1920 "github.com/bnema/vev/internal/protocol/wire"
@@ -917,15 +918,19 @@ func (s *serverSession) Close() error {
917918 // outcome is the peer's departure, so a listener draining its sessions never
918919 // reports it as its own failure.
919920 //
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.
923926 func orderlyDisconnect(err error) bool {
924927 return err == nil ||
925928 errors.Is(err, io.EOF) ||
926929 errors.Is(err, syscall.ECONNRESET) ||
930+ errors.Is(err, syscall.EPIPE) ||
927931 errors.Is(err, net.ErrClosed) ||
928932 errors.Is(err, fs.ErrClosed) ||
933+ errors.Is(err, streamframe.ErrClosed) ||
929934 errors.Is(err, ErrConnectionClosed) ||
930935 errors.Is(err, ErrSessionClosed) ||
931936 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 never918 // 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 sentinel921+// 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 still922+// 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
146146 return nil, ErrInvalidMessage
147147 }
148148 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)
149160 default:
150161 return nil, ErrWrongDirection
151162 }
@@ -167,6 +178,8 @@ func decodeClientEnvelope(envelope *wire.MuxClientEnvelope, maxChunkBytes uint64
167178 return closeFromWire(payload.Close)
168179 case *wire.MuxClientEnvelope_Reset_:
169180 return resetFromWire(payload.Reset_)
181+ case *wire.MuxClientEnvelope_WindowUpdate:
182+ return windowUpdateFromWire(payload.WindowUpdate)
170183 default:
171184 return nil, ErrWrongDirection
172185 }
@@ -233,6 +246,17 @@ func encodeServerEnvelope(message ServerMessage, maxChunkBytes uint64) (*wire.Mu
233246 return nil, ErrInvalidMessage
234247 }
235248 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)
236260 default:
237261 return nil, ErrWrongDirection
238262 }
@@ -260,6 +284,8 @@ func decodeServerEnvelope(envelope *wire.MuxServerEnvelope, maxChunkBytes uint64
260284 return closeFromWire(payload.Close)
261285 case *wire.MuxServerEnvelope_Reset_:
262286 return resetFromWire(payload.Reset_)
287+ case *wire.MuxServerEnvelope_WindowUpdate:
288+ return windowUpdateFromWire(payload.WindowUpdate)
263289 default:
264290 return nil, ErrWrongDirection
265291 }
@@ -470,6 +496,31 @@ func closeFromWire(message *wire.MuxClose) (Close, error) {
470496 return Close{Physical: physical}, nil
471497 }
472498
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+
473524 func resetToWire(m Reset) (*wire.MuxReset, error) {
474525 if err := m.Physical.Validate(); err != nil {
475526 return nil, ErrInvalidMessage
@@ -146,6 +146,17 @@ func encodeClientEnvelope(message ClientMessage, maxChunkBytes uint64) (*wire.Mu
146 return nil, ErrInvalidMessage146 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, ErrWrongDirection161 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, ErrWrongDirection184 return nil, ErrWrongDirection
172 }185 }
@@ -233,6 +246,17 @@ func encodeServerEnvelope(message ServerMessage, maxChunkBytes uint64) (*wire.Mu
233 return nil, ErrInvalidMessage246 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, ErrWrongDirection261 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, ErrWrongDirection290 return nil, ErrWrongDirection
265 }291 }
@@ -470,6 +496,31 @@ func closeFromWire(message *wire.MuxClose) (Close, error) {
470 return Close{Physical: physical}, nil496 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, ErrInvalidMessage526 return nil, ErrInvalidMessage
M internal/adapters/daemonmux/codec_test.go +2 -0
@@ -126,6 +126,7 @@ func TestMuxClientVariants(t *testing.T) {
126126 {"close", Close{Physical: 5}, 303},
127127 {"reset", Reset{Physical: 5, Error: testErrorDetail(), HasError: true}, 304},
128128 {"reset_orderly", Reset{Physical: 5}, 304},
129+ {"window_update", WindowUpdate{Physical: 5, Credit: 4096}, 305},
129130 }
130131 for _, tc := range messages {
131132 t.Run(tc.name, func(t *testing.T) {
@@ -155,6 +156,7 @@ func TestMuxServerVariants(t *testing.T) {
155156 {"close", Close{Physical: 5}, 404},
156157 {"reset", Reset{Physical: 5, Error: testErrorDetail(), HasError: true}, 405},
157158 {"reset_orderly", Reset{Physical: 5}, 405},
159+ {"window_update", WindowUpdate{Physical: 5, Credit: 4096}, 406},
158160 }
159161 for _, tc := range messages {
160162 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 @@
3333 // that always discards the queue immediately. Neither Close nor Reset ever
3434 // terminalizes the physical connection or a sibling stream.
3535 //
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.
4752 //
4853 // Terminal publication is deliberately ordered: a physical terminal outcome
4954 // publishes its own Done first and only then terminalizes every live stream
@@ -71,15 +76,8 @@ import (
7176
7277 // Stream queue bounds. The stream-count and aggregate-bytes ceilings are the
7378 // 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).
7580 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-
8381 // MaxRetiredStreamRecords bounds the settled stream records one engine
8482 // retains so a late frame is still classified as retired instead of
8583 // mistaken for a future stream. It mirrors the negotiated stream ceiling
@@ -109,9 +107,17 @@ var (
109107 // ErrStreamRefMismatch reports an Opened or Refused whose full stream
110108 // reference does not match the one recorded at admission.
111109 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")
115121 // ErrPhysicalClosed reports admission or stream work presented to a
116122 // physical connection that already reached its terminal outcome.
117123 ErrPhysicalClosed = errors.New("daemonmux: physical connection is closed")
@@ -190,6 +196,9 @@ type StreamStatus struct {
190196 // and bytes.
191197 QueuedChunks int
192198 QueuedBytes int
199+ // SendCredit is the remaining outbound credit in credit bytes: payload
200+ // plus MuxChunkCreditOverhead per Data frame (see chunkCredit).
201+ SendCredit uint64
193202 }
194203
195204 // muxStream is one tracked logical stream. Every field is guarded by the
@@ -203,14 +212,25 @@ type muxStream struct {
203212 bytes int
204213 refused bool
205214 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{}
206227 }
207228
208229 // StreamEngine is the in-memory stream table and admission authority of one
209230 // physical daemon connection. It is safe for concurrent use. It owns no I/O.
210231 // 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.
214234 type StreamEngine struct {
215235 mu sync.Mutex
216236 physical *terminalState
@@ -222,8 +242,8 @@ type StreamEngine struct {
222242
223243 maxStreams int
224244 maxChunkBytes uint64
225- maxStreamBytes int
226- maxStreamFrames int
245+ window uint64
246+ returnAt uint64
227247 maxAggregateBytes int
228248
229249 // restricted, when set, binds every fresh inbound Open to the exact
@@ -255,8 +275,8 @@ func newStreamEngine(ceilings MuxCeilings) *StreamEngine {
255275 streams: make(map[PhysicalStreamID]*muxStream, int(ceilings.MaxStreams)),
256276 maxStreams: int(ceilings.MaxStreams),
257277 maxChunkBytes: ceilings.StreamChunkLimit,
258- maxStreamBytes: MaxMuxStreamQueueBytes,
259- maxStreamFrames: MaxMuxStreamQueueChunks,
278+ window: ceilings.StreamWindow(),
279+ returnAt: ceilings.creditReturnThreshold(),
260280 maxAggregateBytes: int(ceilings.MaxAggregateBytes),
261281 }
262282 }
@@ -383,7 +403,7 @@ func (e *StreamEngine) Open(msg Open) error {
383403 if e.live >= e.maxStreams {
384404 return ErrTooManyStreams
385405 }
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{})}
387407 e.highest = id
388408 e.live++
389409 return nil
@@ -445,9 +465,9 @@ func (e *StreamEngine) Refused(msg Refused) error {
445465 // (closing) accepts no new data and refuses it with ErrStreamState, exactly as
446466 // an unconfirmed stream refuses data. A chunk above the negotiated chunk
447467 // 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.
451471 func (e *StreamEngine) Data(msg Data) (StreamDisposition, error) {
452472 if e == nil {
453473 return 0, ErrPhysicalClosed
@@ -469,13 +489,100 @@ func (e *StreamEngine) Data(msg Data) (StreamDisposition, error) {
469489 }
470490 if e.queueOverflowsLocked(stream, len(msg.Data)) {
471491 e.retireLocked(stream)
472- stream.terminal.Fail(domain.RemoteFailureTransport, ErrStreamQueueFull)
492+ stream.terminal.Fail(domain.RemoteFailureInvalidResponse, ErrStreamQueueFull)
473493 return 0, ErrStreamQueueFull
474494 }
475495 e.enqueueLocked(stream, msg.Data)
476496 return StreamAccepted, nil
477497 }
478498
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+
479586 // Close orderly terminates one opening, open, or closing stream. A late close
480587 // for an already terminal or closing stream is discarded. It never
481588 // terminalizes the physical connection or a sibling stream.
@@ -504,6 +611,9 @@ func (e *StreamEngine) Close(msg Close) (StreamDisposition, error) {
504611 return StreamAccepted, nil
505612 }
506613 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)
507617 return StreamAccepted, nil
508618 }
509619
@@ -586,6 +696,7 @@ func (e *StreamEngine) Status(physical PhysicalStreamID) (StreamStatus, bool) {
586696 Refusal: stream.refusal,
587697 QueuedChunks: len(stream.queue),
588698 QueuedBytes: stream.bytes,
699+ SendCredit: stream.sendCredit,
589700 }, true
590701 }
591702
@@ -636,6 +747,7 @@ func (e *StreamEngine) take(physical PhysicalStreamID) ([]byte, bool, bool) {
636747 stream.queue = stream.queue[1:]
637748 stream.bytes -= len(chunk)
638749 e.aggregate -= len(chunk)
750+ stream.recvReturn += chunkCredit(len(chunk))
639751 if stream.state == StreamClosing && len(stream.queue) == 0 {
640752 e.retireLocked(stream)
641753 stream.terminal.Close()
@@ -680,6 +792,8 @@ func (e *StreamEngine) terminalizeAllLocked(publish func(*muxStream)) {
680792 func (e *StreamEngine) retireLocked(stream *muxStream) {
681793 e.releaseQueueLocked(stream)
682794 stream.state = StreamTerminal
795+ stream.sendCredit = 0
796+ wakeCreditLocked(stream)
683797 e.live--
684798 e.retired = append(e.retired, stream.ref.Physical)
685799 for len(e.retired) > MaxRetiredStreamRecords {
@@ -705,11 +819,9 @@ func (e *StreamEngine) releaseQueueLocked(stream *muxStream) {
705819 }
706820
707821 // 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.
710823 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 ||
713825 e.aggregate+n > e.maxAggregateBytes
714826 }
715827
@@ -719,6 +831,7 @@ func (e *StreamEngine) enqueueLocked(stream *muxStream, chunk []byte) {
719831 n := len(chunk)
720832 e.aggregate += n
721833 stream.bytes += n
834+ stream.recvUsed += chunkCredit(n)
722835 owned := make([]byte, n)
723836 copy(owned, chunk)
724837 stream.queue = append(stream.queue, owned)
@@ -33,17 +33,22 @@
33 // that always discards the queue immediately. Neither Close nor Reset ever33 // 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 budget36+// Every stream is credit flow controlled in both directions. Each side starts
37-// before the chunk is copied into the queue: at most MaxMuxStreamQueueChunks37+// with the same per-stream window (MuxCeilings.StreamWindow) of send credit; a
38-// chunks and MaxMuxStreamQueueBytes bytes per stream, the negotiated aggregate38+// Data frame costs its payload plus a fixed overhead (chunkCredit), and the
39-// ceiling (MuxCeilings.MaxAggregateBytes) across the connection, and the39+// receiver returns credit with WindowUpdate as its consumer takes chunks. A
40-// negotiated chunk ceiling (MuxCeilings.StreamChunkLimit) per chunk. A chunk40+// conforming sender therefore waits for credit instead of overrunning the
41-// above the negotiated chunk ceiling is refused statelessly with ErrTooLarge41+// 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 would42+//
43-// exceed a queue bound resets exactly that stream and releases the bytes it43+// 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-stream44+// 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 the45+// 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 outcome53 // Terminal publication is deliberately ordered: a physical terminal outcome
49 // publishes its own Done first and only then terminalizes every live stream54 // 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 the77 // Stream queue bounds. The stream-count and aggregate-bytes ceilings are the
73 // negotiated maxima from limits.go (MaxMuxStreams, MaxMuxAggregateBytes); the78 // 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 engine81 // MaxRetiredStreamRecords bounds the settled stream records one engine
84 // retains so a late frame is still classified as retired instead of82 // retains so a late frame is still classified as retired instead of
85 // mistaken for a future stream. It mirrors the negotiated stream ceiling83 // 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 stream107 // 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 queue110+ // 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 a121 // 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 int197 QueuedChunks int
192 QueuedBytes int198 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 the204 // muxStream is one tracked logical stream. Every field is guarded by the
@@ -203,14 +212,25 @@ type muxStream struct {
203 bytes int212 bytes int
204 refused bool213 refused bool
205 refusal ErrorDetail214 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 one229 // 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 negotiated231 // 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 and233+// 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.Mutex235 mu sync.Mutex
216 physical *terminalState236 physical *terminalState
@@ -222,8 +242,8 @@ type StreamEngine struct {
222 242
223 maxStreams int243 maxStreams int
224 maxChunkBytes uint64244 maxChunkBytes uint64
225- maxStreamBytes int245+ window uint64
226- maxStreamFrames int246+ returnAt uint64
227 maxAggregateBytes int247 maxAggregateBytes int
228 248
229 // restricted, when set, binds every fresh inbound Open to the exact249 // 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 ErrTooManyStreams404 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 = id407 e.highest = id
388 e.live++408 e.live++
389 return nil409 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 as465 // (closing) accepts no new data and refuses it with ErrStreamState, exactly as
446 // an unconfirmed stream refuses data. A chunk above the negotiated chunk466 // an unconfirmed stream refuses data. A chunk above the negotiated chunk
447 // ceiling is refused statelessly with ErrTooLarge and mutates nothing. A chunk467 // ceiling is refused statelessly with ErrTooLarge and mutates nothing. A chunk
448-// that would exceed the per-stream chunk, the per-stream byte, or the468+// 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 it469+// 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, ErrPhysicalClosed473 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, ErrStreamQueueFull493 return 0, ErrStreamQueueFull
474 }494 }
475 e.enqueueLocked(stream, msg.Data)495 e.enqueueLocked(stream, msg.Data)
476 return StreamAccepted, nil496 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 close586 // Close orderly terminates one opening, open, or closing stream. A late close
480 // for an already terminal or closing stream is discarded. It never587 // 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, nil611 return StreamAccepted, nil
505 }612 }
506 stream.state = StreamClosing613 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, nil617 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 }, true700 }, 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 = StreamTerminal794 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 the821 // queueOverflowsLocked reports whether one inbound chunk would exceed the
708-// per-stream chunk bound, the per-stream byte bound, or the negotiated822+// 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.maxAggregateBytes825 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 += n832 e.aggregate += n
721 stream.bytes += n833 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) {
480480 require.Equal(t, 2, e.AggregateBytes())
481481 }
482482
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.
485494 func TestStreamEngineQueueOverflow(t *testing.T) {
486495 e := testEngine()
487496 openTestStream(t, e, 1)
@@ -489,14 +498,15 @@ func TestStreamEngineQueueOverflow(t *testing.T) {
489498 _, err := e.Data(Data{Physical: 2, Data: []byte("sibling")})
490499 require.NoError(t, err)
491500
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})
494504 require.NoError(t, err)
495505 require.Equal(t, StreamAccepted, disposition)
496506 }
497- require.Equal(t, MaxMuxStreamQueueChunks, mustStatus(t, e, 1).QueuedChunks)
507+ require.Equal(t, full, mustStatus(t, e, 1).QueuedChunks)
498508
499- disposition, err := e.Data(Data{Physical: 1, Data: []byte{'x'}})
509+ disposition, err := e.Data(Data{Physical: 1, Data: chunk})
500510 require.ErrorIs(t, err, ErrStreamQueueFull)
501511 require.Zero(t, disposition)
502512
@@ -504,7 +514,7 @@ func TestStreamEngineQueueOverflow(t *testing.T) {
504514 require.Equal(t, StreamTerminal, status.State)
505515 require.True(t, channelClosed(status.Done))
506516 require.ErrorIs(t, status.Err, ErrStreamQueueFull)
507- require.Equal(t, domain.RemoteFailureTransport, status.FailureKind)
517+ require.Equal(t, domain.RemoteFailureInvalidResponse, status.FailureKind)
508518 require.Zero(t, status.QueuedChunks)
509519 require.Zero(t, status.QueuedBytes)
510520 require.Equal(t, len("sibling"), e.AggregateBytes(), "the overflowed stream released its budget")
@@ -527,36 +537,16 @@ func TestStreamEngineQueueOverflow(t *testing.T) {
527537 require.ErrorIs(t, mustStatus(t, e, 1).Err, ErrStreamQueueFull)
528538 }
529539
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.
532542 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-
555543 t.Run("aggregate byte bound", func(t *testing.T) {
556544 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.
559548 e.maxChunkBytes = 16 << 20
549+ e.window = 32 << 20
560550 chunk := make([]byte, 16<<20)
561551 for i := 1; i <= 4; i++ {
562552 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 own483+// 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 byte540+// 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 the545+ // 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 << 20548 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 (
8181 // MaxMuxAggregateBytes is the largest legal aggregate buffered-bytes
8282 // ceiling: 64 MiB across every live stream of one physical connection.
8383 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
8492 )
8593
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+
86121 // ErrInvalidCeilings reports an advertisement outside its window: a zero,
87122 // below-floor, or above-ceiling envelope, chunk, stream-count, or aggregate
88123 // bound. The preamble maps it to its own ErrPreambleRejected sentinel; the
@@ -159,13 +194,21 @@ func DefaultMuxCeilings() MuxCeilings {
159194 // the local policy. Each field is independent, so a peer that offers less in
160195 // one dimension lowers only that dimension. Both inputs are assumed
161196 // 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.
162202 func EffectiveMuxCeilings(local, remote MuxCeilings) MuxCeilings {
163- return MuxCeilings{
203+ effective := MuxCeilings{
164204 MaxReceiveEnvelopeBytes: min(local.MaxReceiveEnvelopeBytes, remote.MaxReceiveEnvelopeBytes),
165205 StreamChunkLimit: min(local.StreamChunkLimit, remote.StreamChunkLimit),
166206 MaxStreams: min(local.MaxStreams, remote.MaxStreams),
167207 MaxAggregateBytes: min(local.MaxAggregateBytes, remote.MaxAggregateBytes),
168208 }
209+ fair := effective.MaxAggregateBytes / chunkCredit(int(effective.StreamChunkLimit))
210+ effective.MaxStreams = max(min(effective.MaxStreams, fair), MinMuxStreams)
211+ return effective
169212 }
170213
171214 // checkMuxCeilings refuses an invalid advertisement as a preamble refusal so
@@ -81,8 +81,43 @@ const (
81 // MaxMuxAggregateBytes is the largest legal aggregate buffered-bytes81 // 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 << 2083 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 aggregate122 // below-floor, or above-ceiling envelope, chunk, stream-count, or aggregate
88 // bound. The preamble maps it to its own ErrPreambleRejected sentinel; the123 // 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 in194 // the local policy. Each field is independent, so a peer that offers less in
160 // one dimension lowers only that dimension. Both inputs are assumed195 // 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 so214 // 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) {
747747 daemon.await(t, 1)
748748
749749 // 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))
751751
752752 malformed := requireStreamState(t, h.daemonPump, 1, StreamTerminal)
753753 require.Equal(t, domain.RemoteFailureInvalidResponse, malformed.FailureKind)
@@ -928,7 +928,7 @@ func TestListenerConnectionPreambleFailureLeavesNoResidue(t *testing.T) {
928928
929929 // The peer's first frame is not a valid session preamble; the failed
930930 // 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))
932932 _, err := daemonConnection.ReceiveClient()
933933 require.Error(t, err)
934934
@@ -1042,7 +1042,7 @@ func TestListenerConnectionCloseDiscardsQueuedInboundReleasesAccounting(t *testi
10421042
10431043 // The broker sent one chunk the daemon never reads, so the stream holds
10441044 // 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))
10461046 requireEngineEventually(t, h.daemonPump, func(e *StreamEngine) bool { return e.AggregateBytes() > 0 },
10471047 "cycle %d: the accepted chunk was never queued", i)
10481048
@@ -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 failed929 // 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 holds1043 // 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) {
551551 s.mu.Unlock()
552552 continue
553553 }
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.
557559 return 0, io.EOF
558560 }
559561 select {
@@ -569,8 +571,10 @@ func (s *muxStreamPipe) Read(p []byte) (int, error) {
569571 }
570572
571573 // 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.
574578 func (s *muxStreamPipe) Write(p []byte) (int, error) {
575579 written := 0
576580 for len(p) > 0 {
@@ -581,8 +585,8 @@ func (s *muxStreamPipe) Write(p []byte) (int, error) {
581585 if uint64(n) > s.limit {
582586 n = int(s.limit)
583587 }
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) {
586590 return written, ErrLogicalClosed
587591 }
588592 return written, err
@@ -551,9 +551,11 @@ func (s *muxStreamPipe) Read(p []byte) (int, error) {
551 s.mu.Unlock()551 s.mu.Unlock()
552 continue552 continue
553 }553 }
554- if watchClosed(watch) {554+ if watchSettled(watch) {
555- // Watch returns an already-closed channel only for a terminal or555+ // 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.EOF559 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 ceiling573 // 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 an574+// 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 := 0579 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, ErrLogicalClosed590 return written, ErrLogicalClosed
587 }591 }
588 return written, err592 return written, err
M internal/adapters/daemonmux/logical_conn_test.go +28 -9
@@ -557,15 +557,22 @@ func TestLogicalHundredConnectionsConcurrentAdmission(t *testing.T) {
557557 require.False(t, channelClosed(daemonPump.Done()))
558558 }
559559
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.
563566 func TestLogicalBlockedReaderSiblingProgresses(t *testing.T) {
564567 brokerPump, daemonPump := newPairedPumps(t, DefaultMuxCeilings())
565568 connector := mustConnector(t, brokerPump)
566569 deadline := time.Now().Add(15 * time.Second)
567570
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)
569576 blockedAcceptor.start(1, deadline)
570577 siblingAcceptor := newDaemonAcceptor(daemonPump, 1)
571578 siblingAcceptor.start(2, deadline)
@@ -580,11 +587,14 @@ func TestLogicalBlockedReaderSiblingProgresses(t *testing.T) {
580587 require.NoError(t, err)
581588 require.Equal(t, protocol.Pong{}, message)
582589
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.
585592 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")
588598
589599 // The sibling still progresses and the physical connection is untouched.
590600 require.NoError(t, sibling.SendClient(protocol.Ping{}))
@@ -593,6 +603,15 @@ func TestLogicalBlockedReaderSiblingProgresses(t *testing.T) {
593603 require.Equal(t, protocol.Pong{}, message)
594604 require.False(t, channelClosed(brokerPump.Done()))
595605 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)
596615 }
597616
598617 // TestLogicalMalformedInnerFrameResetsOnlyStream proves a malformed inner
@@ -620,7 +639,7 @@ func TestLogicalMalformedInnerFrameResetsOnlyStream(t *testing.T) {
620639
621640 // A zero-length inner frame is malformed: the client's first read fails and
622641 // 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))
624643 _, err = malformed.ReceiveServer()
625644 require.Error(t, err)
626645 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 whose560+// TestLogicalBlockedReaderSiblingProgresses is the regression for a slow
561-// consumer never reads lets only its own bounded stream reset while a sibling561+// 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 floods590+ // 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 inner617 // 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 and640 // 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 {
126126 func (Reset) muxClientMessage() {}
127127 func (Reset) muxServerMessage() {}
128128
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+
129141 // Opened confirms one logical stream is established.
130142 type Opened struct {
131143 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 StreamRef143 Ref StreamRef
M internal/adapters/daemonmux/preamble_test.go +35 -0
@@ -300,6 +300,41 @@ func TestEffectiveMuxCeilings(t *testing.T) {
300300 require.Equal(t, effective, EffectiveMuxCeilings(effective, effective))
301301 }
302302
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+
303338 // TestValidatePreambleResponseAgainstOffer proves an accepted response is
304339 // refused when any effective ceiling exceeds the local offer, and accepted
305340 // 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 is338 // TestValidatePreambleResponseAgainstOffer proves an accepted response is
304 // refused when any effective ceiling exceeds the local offer, and accepted339 // 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 a340 // 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
6464 import (
6565 "context"
6666 "errors"
67+ "log/slog"
6768 "sync"
6869
6970 "github.com/bnema/vev/internal/domain"
@@ -90,6 +91,10 @@ type FramedCarrier interface {
9091 // unknown inbound direction.
9192 var ErrPumpConfig = errors.New("daemonmux: invalid pump configuration")
9293
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+
9398 // ErrAdmissionObserverLate reports an admission-observer registration attempted
9499 // after Start. The reader goroutine is already running and may have admitted an
95100 // 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) {
310315 if finalized {
311316 p.signalWatch(physical)
312317 }
318+ if ok {
319+ p.returnCredit(physical)
320+ }
313321 return chunk, ok
314322 }
315323
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+
316375 // Ceilings returns the negotiated ceilings the pump enforces. The value is
317376 // immutable for the pump's lifetime.
318377 func (p *Pump) Ceilings() MuxCeilings {
@@ -471,9 +530,15 @@ var closedWatchChan = func() <-chan struct{} {
471530
472531 func closedChannel() <-chan struct{} { return closedWatchChan }
473532
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.
477542 func watchClosed(ch <-chan struct{}) bool {
478543 select {
479544 case <-ch:
@@ -490,8 +555,9 @@ func watchClosed(ch <-chan struct{}) bool {
490555 // values satisfy both. A wrong-direction value fails with ErrWrongDirection
491556 // before anything is encoded or queued, a message the codec refuses propagates
492557 // 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.
495561 //
496562 // An accepted Send only means the frame is queued, never that it reached the
497563 // wire: the writer drains asynchronously. Call Flush to wait until every
@@ -509,9 +575,19 @@ func (p *Pump) Send(message any) error {
509575 if message == nil {
510576 return ErrInvalidMessage
511577 }
578+ switch message.(type) {
579+ case Data, *Data:
580+ return ErrDataNeedsCredit
581+ }
512582 if p.isTerminal() {
513583 return ErrPhysicalClosed
514584 }
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 {
515591 switch p.local {
516592 case DirectionClient:
517593 client, ok := message.(ClientMessage)
@@ -756,12 +832,20 @@ func (p *Pump) applyClient(message ClientMessage) error {
756832 return p.applyClient(*m)
757833 case Reset:
758834 disposition, err := p.engine.Reset(m)
835+ p.logPeerReset(m, disposition)
759836 return p.applyStreamTerminal(m.Physical, disposition, err)
760837 case *Reset:
761838 if m == nil {
762839 return ErrInvalidMessage
763840 }
764841 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)
765849 default:
766850 return ErrWrongDirection
767851 }
@@ -848,12 +932,20 @@ func (p *Pump) applyServer(message ServerMessage) error {
848932 return p.applyServer(*m)
849933 case Reset:
850934 disposition, err := p.engine.Reset(m)
935+ p.logPeerReset(m, disposition)
851936 return p.applyStreamTerminal(m.Physical, disposition, err)
852937 case *Reset:
853938 if m == nil {
854939 return ErrInvalidMessage
855940 }
856941 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)
857949 default:
858950 return ErrWrongDirection
859951 }
@@ -876,6 +968,25 @@ func (p *Pump) applyStreamTerminal(physical PhysicalStreamID, disposition Stream
876968 return nil
877969 }
878970
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+
879990 // applyData queues one inbound chunk and classifies the engine's refusal.
880991 func (p *Pump) applyData(message Data) error {
881992 _, err := p.engine.Data(message)
@@ -917,6 +1028,7 @@ func (p *Pump) scheduleReset(physical PhysicalStreamID, cause error) {
9171028 }
9181029 detail := resetErrorDetail(cause)
9191030 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)
9201032 _, _ = p.engine.Reset(reset)
9211033 var err error
9221034 if p.local == DirectionServer {
@@ -941,6 +1053,7 @@ func (p *Pump) settleFailure(kind domain.RemoteFailureKind, cause error) {
9411053 p.termMu.Lock()
9421054 if !p.settled {
9431055 p.settled = true
1056+ slog.Warn("daemonmux_physical_failed", "side", p.local.String(), "kind", kind.String(), "live_streams", p.engine.Live(), "cause", cause)
9441057 p.terminal.Fail(kind, cause)
9451058 p.engine.Fail(kind, cause)
9461059 }
@@ -1025,10 +1138,9 @@ func openRefusalDetail(cause error) ErrorDetail {
10251138 // response.
10261139 func resetErrorDetail(cause error) ErrorDetail {
10271140 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.
10321144 kind = domain.RemoteFailureTransport
10331145 }
10341146 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 attempted98 // ErrAdmissionObserverLate reports an admission-observer registration attempted
94 // after Start. The reader goroutine is already running and may have admitted an99 // 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 must100 // 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, ok321 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 is375 // 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 another534+// 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 ErrWrongDirection555 // values satisfy both. A wrong-direction value fails with ErrWrongDirection
491 // before anything is encoded or queued, a message the codec refuses propagates556 // 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 outcome557 // the codec's error, and a pump that already reached its terminal outcome
493-// fails with ErrPhysicalClosed. On success the encoded bytes are immutable and558+// 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 the562 // 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 every563 // 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 ErrInvalidMessage576 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 ErrPhysicalClosed583 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 ErrInvalidMessage839 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 ErrWrongDirection850 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 ErrInvalidMessage939 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 ErrWrongDirection950 return ErrWrongDirection
859 }951 }
@@ -876,6 +968,25 @@ func (p *Pump) applyStreamTerminal(physical PhysicalStreamID, disposition Stream
876 return nil968 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 error1033 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 = true1055 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.RemoteFailureInvalidResponse1140 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 either1142+ // The local aggregate safety net: a transport-class abort, not an
1030- // direction: the stream was reset because a bounded queue could not1143+ // invalid peer frame.
1031- // hold the frame, not because the peer sent something invalid.
1032 kind = domain.RemoteFailureTransport1144 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) {
2424 require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)}))
2525 // A queued outbound frame proves the duplicate Open neither resets the
2626 // 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))
2828 before := mustStatus(t, pump.Engine(), 1)
2929 watch := pump.Watch(1)
3030
@@ -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 the25 // 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) {
296296 require.NoError(t, pump.Close())
297297 require.True(t, channelClosed(pump.Done()))
298298 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")
300302 })
301303 }
302304
@@ -329,7 +331,7 @@ func TestPumpTwoAndHundredStreams(t *testing.T) {
329331 require.Equal(t, count-1, pump.Engine().AggregateBytes())
330332
331333 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))
333335 }
334336 seen := make(map[PhysicalStreamID]int, count)
335337 for i := 0; i < count; i++ {
@@ -401,18 +403,20 @@ func TestPumpBlockedConsumerDoesNotBlockReaderOrSibling(t *testing.T) {
401403 require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))}))
402404 }
403405
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})
407411 }
408412 requireEngineEventually(t, pump, func(e *StreamEngine) bool {
409413 status, ok := e.Status(1)
410- return ok && status.QueuedChunks == MaxMuxStreamQueueChunks
414+ return ok && status.QueuedChunks == full
411415 })
412416
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})
416420 deliverClientFrame(t, carrier, Data{Physical: 2, Data: []byte("sibling")})
417421
418422 taken, ok := takeEventually(t, pump, 2)
@@ -422,7 +426,7 @@ func TestPumpBlockedConsumerDoesNotBlockReaderOrSibling(t *testing.T) {
422426 stalled := mustStatus(t, pump.Engine(), 1)
423427 require.Equal(t, StreamTerminal, stalled.State)
424428 require.ErrorIs(t, stalled.Err, ErrStreamQueueFull)
425- require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind)
429+ require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind)
426430 require.Zero(t, stalled.QueuedChunks)
427431 require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State)
428432 require.False(t, pump.Engine().Closed())
@@ -430,7 +434,7 @@ func TestPumpBlockedConsumerDoesNotBlockReaderOrSibling(t *testing.T) {
430434
431435 reset := decodeReset(t, carrier.nextSent(t), DirectionServer)
432436 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)
434438 }
435439
436440 // TestPumpBlockedWriterDoesNotBlockReader proves the reader keeps applying
@@ -480,7 +484,7 @@ func TestPumpFairWriterProgress(t *testing.T) {
480484 const rounds = 2
481485 for round := 0; round < rounds; round++ {
482486 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))
484488 }
485489 }
486490
@@ -561,14 +565,15 @@ func TestPumpResetIsolation(t *testing.T) {
561565 require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(id)}))
562566 }
563567
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})
566571 }
567572 requireEngineEventually(t, pump, func(e *StreamEngine) bool {
568573 status, ok := e.Status(1)
569- return ok && status.QueuedChunks == MaxMuxStreamQueueChunks
574+ return ok && status.QueuedChunks == full
570575 })
571- deliverClientFrame(t, carrier, Data{Physical: 1, Data: []byte{'x'}})
576+ deliverClientFrame(t, carrier, Data{Physical: 1, Data: chunk})
572577 requireEngineEventually(t, pump, func(e *StreamEngine) bool {
573578 status, ok := e.Status(1)
574579 return ok && status.State == StreamTerminal
@@ -581,7 +586,7 @@ func TestPumpResetIsolation(t *testing.T) {
581586
582587 reset := decodeReset(t, carrier.nextSent(t), DirectionServer)
583588 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)
585590 require.False(t, pump.Engine().Closed())
586591 })
587592 }
@@ -767,7 +772,7 @@ func TestPumpConcurrentClose(t *testing.T) {
767772 require.Equal(t, 1, carrier.closeCalls())
768773 require.True(t, channelClosed(pump.readerDone))
769774 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)
771776 }
772777
773778 // TestPumpCloseBeforeStart proves Close never starts or joins goroutines that
@@ -891,7 +896,7 @@ func TestPumpFlushHonoursContext(t *testing.T) {
891896 require.NoError(t, pump.Engine().Open(Open{Ref: testRef(1)}))
892897 require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)}))
893898 // 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))
895900
896901 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
897902 defer cancel()
@@ -919,7 +924,7 @@ func TestPumpFlushCancellationMayDrop(t *testing.T) {
919924
920925 hold := make(chan struct{})
921926 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))
923928 require.Eventually(t, func() bool { return carrier.enteredSends() >= 1 }, 5*time.Second, time.Millisecond)
924929 // The final Close is queued behind the blocked write.
925930 require.NoError(t, pump.Send(ServerMessage(Close{Physical: 1})))
@@ -930,38 +935,122 @@ func TestPumpFlushCancellationMayDrop(t *testing.T) {
930935 carrier.requireNoSent(t, 50*time.Millisecond)
931936 }
932937
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) {
937942 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 })
939946 require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(1)}))
940947
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))
944951 }
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")
954964
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)
9551030 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)}))
9601034
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")
9651054 }
9661055
9671056 // TestPumpInboundTerminalRetiresQueuedOutbound proves an inbound Close or Reset
@@ -975,7 +1064,7 @@ func TestPumpInboundTerminalRetiresQueuedOutbound(t *testing.T) {
9751064 id := PhysicalStreamID(i)
9761065 require.NoError(t, pump.Engine().Open(Open{Ref: testRef(id)}))
9771066 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))
9791068 require.Equal(t, 1, pump.scheduler.QueuedFrames(id))
9801069
9811070 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 == MaxMuxStreamQueueChunks414+ return ok && status.QueuedChunks == full
411 })415 })
412 416
413- // One more chunk overflows stream 1's own bound; a sibling chunk still417+ // 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 applying440 // TestPumpBlockedWriterDoesNotBlockReader proves the reader keeps applying
@@ -480,7 +484,7 @@ func TestPumpFairWriterProgress(t *testing.T) {
480 const rounds = 2484 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 == MaxMuxStreamQueueChunks574+ 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 == StreamTerminal579 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 that778 // 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 overflow938+// TestPumpSendDataBackpressure proves a sender that exhausted its stream
934-// discards the stream's queued frames, resets its engine record to free the939+// 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 retired1035+ 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 Reset1056 // 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) {
273273 require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))}))
274274 }
275275
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})
279281 }
280282 requireEngineEventually(t, pump, func(e *StreamEngine) bool {
281283 status, ok := e.Status(1)
282- return ok && status.QueuedChunks == MaxMuxStreamQueueChunks
284+ return ok && status.QueuedChunks == full
283285 })
284286
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})
288290 injectQUICClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")})
289291
290292 taken, ok := takeEventually(t, pump, 2)
@@ -294,14 +296,14 @@ func TestQUICCarriageBlockedConsumerSiblingProgress(t *testing.T) {
294296 stalled := mustStatus(t, pump.Engine(), 1)
295297 require.Equal(t, StreamTerminal, stalled.State)
296298 require.ErrorIs(t, stalled.Err, ErrStreamQueueFull)
297- require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind)
299+ require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind)
298300 require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State)
299301 require.False(t, pump.Engine().Closed())
300302 require.False(t, channelClosed(pump.Done()))
301303
302304 reset := decodeReset(t, nextQUICServerFrame(t, client), DirectionServer)
303305 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)
305307 }
306308
307309 // 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 == MaxMuxStreamQueueChunks284+ return ok && status.QueuedChunks == full
283 })285 })
284 286
285- // One more chunk overflows stream 1's own bound; a sibling chunk still287+ // 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 one309 // TestQUICCarriageResetIsolation proves, over a real QUIC carriage, that one
M internal/adapters/daemonmux/scheduler.go +30 -22
@@ -19,13 +19,14 @@
1919 // a refused Open is still answered; that refusal seals its own record. A
2020 // duplicate terminal, a duplicate refusal, and data for a settled, unadmitted,
2121 // 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.
2930 //
3031 // An overflowed stream is not sealed outright: the discard settles its data
3132 // but leaves its record open for exactly one terminal Close or Reset, so the
@@ -126,9 +127,8 @@ var (
126127 // stream fencing.
127128 ErrSchedulerSettled = errors.New("daemonmux: stream is settled for scheduling")
128129 // 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
132132 // overflowed and is awaiting its one terminal Close or Reset: the data was
133133 // discarded and the record is not yet sealed.
134134 ErrSchedulerFull = errors.New("daemonmux: scheduler queue overflow")
@@ -253,8 +253,6 @@ type Scheduler struct {
253253 // Bounds and quantum are constructed from the package defaults and are
254254 // owned by the scheduler; they are fields so a test can tighten them.
255255 maxAggregateBytes int
256- maxStreamBytes int
257- maxStreamFrames int
258256 quantum int64
259257 }
260258
@@ -280,8 +278,6 @@ func newScheduler(engine *StreamEngine, ceilings MuxCeilings) *Scheduler {
280278 maxEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes,
281279 maxChunkBytes: ceilings.StreamChunkLimit,
282280 maxAggregateBytes: int(ceilings.MaxAggregateBytes),
283- maxStreamBytes: MaxMuxStreamQueueBytes,
284- maxStreamFrames: MaxMuxStreamQueueChunks,
285281 quantum: SchedulerQuantum,
286282 controlBurst: MaxMuxSchedulerControlBurst,
287283 }
@@ -354,7 +350,7 @@ func (s *Scheduler) Refuse(message Refused) error {
354350 stream = &schedStream{physical: physical}
355351 s.streams[physical] = stream
356352 }
357- if s.overflowLocked(stream, len(frame.bytes)) {
353+ if s.overflowLocked(len(frame.bytes)) {
358354 s.overflowLockedStream(stream)
359355 return ErrSchedulerFull
360356 }
@@ -436,7 +432,7 @@ func (s *Scheduler) enqueue(frame schedFrame, physical PhysicalStreamID) error {
436432 stream = &schedStream{physical: physical}
437433 s.streams[physical] = stream
438434 }
439- if s.overflowLocked(stream, len(frame.bytes)) {
435+ if s.overflowLocked(len(frame.bytes)) {
440436 s.overflowLockedStream(stream)
441437 return ErrSchedulerFull
442438 }
@@ -651,12 +647,10 @@ func (s *Scheduler) listRetiredLocked(stream *schedStream) {
651647 }
652648 }
653649
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
660654 }
661655
662656 // discardLocked drops every queued frame of one stream and returns its bytes
@@ -891,6 +885,13 @@ func clientFrame(message ClientMessage) (PhysicalStreamID, FrameClass, error) {
891885 return 0, 0, ErrInvalidMessage
892886 }
893887 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)
894895 default:
895896 return 0, 0, ErrWrongDirection
896897 }
@@ -935,6 +936,13 @@ func serverFrame(message ServerMessage) (PhysicalStreamID, FrameClass, error) {
935936 return 0, 0, ErrInvalidMessage
936937 }
937938 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)
938946 default:
939947 return 0, 0, ErrWrongDirection
940948 }
@@ -19,13 +19,14 @@
19 // a refused Open is still answered; that refusal seals its own record. A19 // 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 (see21 // or closing identity stay refused while the settling record is retained (see
22-// below). Each stream queues at most MaxMuxStreamQueueChunks (8) envelopes and22+// below). Data frames are admitted only after the writer spent the stream's
23-// MaxMuxStreamQueueBytes (32 MiB), and all streams together queue at most23+// send credit (see StreamEngine), so each stream's queued data never exceeds
24-// MaxMuxAggregateBytes (64 MiB) of application data. A frame is charged24+// 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 would25+// 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 the26+// that would exceed it discards exactly its own stream queue, frees that
27-// aggregate byte bound discards exactly its own stream queue, frees that27+// 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 data31 // 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 the32 // 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 the129 // ErrSchedulerFull reports an outbound frame that would exceed the
129- // per-stream frame bound, the per-stream byte bound, or the aggregate byte130+ // aggregate byte bound. The offending stream's queue is discarded and its
130- // bound. The offending stream's queue is discarded and its budget131+ // 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 was132 // 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 are253 // 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 int255 maxAggregateBytes int
256- maxStreamBytes int
257- maxStreamFrames int
258 quantum int64256 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] = stream351 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 ErrSchedulerFull355 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] = stream433 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 ErrSchedulerFull437 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 frame650+// 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 bytes656 // 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, ErrInvalidMessage885 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, ErrWrongDirection896 return 0, 0, ErrWrongDirection
896 }897 }
@@ -935,6 +936,13 @@ func serverFrame(message ServerMessage) (PhysicalStreamID, FrameClass, error) {
935 return 0, 0, ErrInvalidMessage936 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, ErrWrongDirection947 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 {
3636 return active
3737 }
3838
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+
3951 // TestSchedulerConstants pins the negotiated bounds the scheduler is specified
4052 // to enforce.
4153 func TestSchedulerConstants(t *testing.T) {
42- require.Equal(t, 8, MaxMuxStreamQueueChunks)
43- require.Equal(t, 32<<20, MaxMuxStreamQueueBytes)
4454 require.Equal(t, 64<<20, int(MaxMuxAggregateBytes))
55+ require.Equal(t, uint64(512<<10), DefaultMuxCeilings().StreamWindow())
4556 require.Equal(t, int64(64<<10), SchedulerQuantum)
4657 require.Equal(t, 4, MaxMuxSchedulerControlBurst)
4758 }
@@ -180,8 +191,9 @@ func TestSchedulerStalledAndHotStream(t *testing.T) {
180191
181192 // The hot stream keeps a maximal backlog; the siblings are refilled to full
182193 // whenever the round drains a slot.
194+ const backlog = 8
183195 fill := func(id PhysicalStreamID, seed byte) {
184- for scheduler.QueuedFrames(id) < MaxMuxStreamQueueChunks {
196+ for scheduler.QueuedFrames(id) < backlog {
185197 require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte{seed}}))
186198 }
187199 }
@@ -315,45 +327,9 @@ func TestSchedulerResetDiscard(t *testing.T) {
315327 require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: 3, Data: []byte("x")}), ErrSchedulerSettled)
316328 }
317329
318-// TestSchedulerAggregateAndPerStreamCaps proves each bound discards exactly the
330+// TestSchedulerAggregateCap proves the aggregate bound discards exactly the
319331 // 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) {
357333 t.Run("aggregate byte bound", func(t *testing.T) {
358334 _, scheduler, ids := testScheduler(t, 3)
359335 payload := make([]byte, 300)
@@ -361,8 +337,6 @@ func TestSchedulerAggregateAndPerStreamCaps(t *testing.T) {
361337 unit := scheduler.QueuedBytes(ids[0])
362338 require.Positive(t, unit)
363339
364- scheduler.maxStreamFrames = 100
365- scheduler.maxStreamBytes = unit * 100
366340 scheduler.maxAggregateBytes = unit * 3
367341
368342 require.NoError(t, scheduler.EnqueueClient(Data{Physical: ids[0], Data: payload}))
@@ -561,8 +535,6 @@ func TestNewSchedulerWithCeilings(t *testing.T) {
561535 // the offending stream and frees its budget.
562536 scheduler, err := NewSchedulerWithCeilings(nil, probeCeiling)
563537 require.NoError(t, err)
564- scheduler.maxStreamFrames = 100
565- scheduler.maxStreamBytes = unit * 100
566538 scheduler.maxAggregateBytes = unit * 2
567539 // The negotiated chunk ceiling is refused statelessly.
568540 require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: 1, Data: make([]byte, 9)}), ErrTooLarge)
@@ -610,11 +582,12 @@ func TestSchedulerTerminalBypass(t *testing.T) {
610582 engine := testEngine()
611583 scheduler := NewScheduler(engine)
612584 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})
615588 require.NoError(t, err)
616589 }
617- _, err := engine.Data(Data{Physical: 1, Data: []byte{'x'}})
590+ _, err := engine.Data(Data{Physical: 1, Data: chunk})
618591 require.ErrorIs(t, err, ErrStreamQueueFull)
619592 require.Equal(t, StreamTerminal, mustStatus(t, engine, 1).State)
620593
@@ -746,8 +719,8 @@ func TestSchedulerOverflowAllowsOneTerminal(t *testing.T) {
746719 t.Run(terminal.name, func(t *testing.T) {
747720 _, scheduler, ids := testScheduler(t, 1)
748721 id := ids[0]
749- scheduler.maxStreamFrames = 2
750722 require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")}))
723+ scheduler.maxAggregateBytes = 2 * scheduler.AggregateBytes()
751724 require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("b")}))
752725 require.ErrorIs(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("c")}), ErrSchedulerFull)
753726 require.Zero(t, scheduler.QueuedFrames(id))
@@ -779,12 +752,10 @@ func TestSchedulerOverflowAllowsOneTerminal(t *testing.T) {
779752 // MaxRetiredSchedulerRecords.
780753 func TestSchedulerOverflowRecordsBounded(t *testing.T) {
781754 scheduler := NewScheduler(nil)
782- scheduler.maxStreamFrames = 1
783755 const churn = MaxRetiredSchedulerRecords * 4
784756 for i := 1; i <= churn; i++ {
785757 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)
788759 }
789760 require.LessOrEqual(t, len(scheduler.streams), MaxRetiredSchedulerRecords)
790761 require.Zero(t, scheduler.AggregateBytes())
@@ -799,14 +770,12 @@ func TestSchedulerOverflowRecordsBounded(t *testing.T) {
799770 // stream table to the configured bound.
800771 func TestSchedulerOverflowTerminalReclaimsRetiredRecords(t *testing.T) {
801772 scheduler := NewScheduler(nil)
802- scheduler.maxStreamFrames = 1
803773 const churn = MaxRetiredSchedulerRecords + 64
804774
805775 for i := 1; i <= churn; i++ {
806776 id := PhysicalStreamID(i)
807- require.NoError(t, scheduler.EnqueueClient(Data{Physical: id, Data: []byte("a")}))
808777 // 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)
810779 // The one terminal the overflow leaves open lands while the record sits
811780 // in the retired list; later streams force it past the bound.
812781 require.NoError(t, scheduler.EnqueueClient(Reset{Physical: id}))
@@ -36,12 +36,23 @@ func countActive(scheduler *Scheduler, ids []PhysicalStreamID) int {
36 return active36 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 specified51 // 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 full192 // 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 the330+// 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 * 3340 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 * 2538 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 * 4755 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 + 64773 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 sits779 // 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) {
491491 // in-process relay end. Every byte crosses the real subprocess pipes.
492492 func sshRawCarriagePair(t *testing.T) (RawFramedTransport, RawFramedTransport) {
493493 t.Helper()
494- sockPath := filepath.Join(t.TempDir(), "relay.sock")
494+ sockPath := filepath.Join(filepath.Dir(muxCarriagePath(t)), "relay.sock")
495495 listener, err := net.Listen("unix", sockPath)
496496 require.NoError(t, err)
497497 t.Cleanup(func() { _ = listener.Close() })
@@ -571,18 +571,20 @@ func TestSSHCarriageBlockedConsumerSiblingProgress(t *testing.T) {
571571 require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))}))
572572 }
573573
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})
577579 }
578580 requireEngineEventually(t, pump, func(e *StreamEngine) bool {
579581 status, ok := e.Status(1)
580- return ok && status.QueuedChunks == MaxMuxStreamQueueChunks
582+ return ok && status.QueuedChunks == full
581583 })
582584
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})
586588 injectSSHClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")})
587589
588590 taken, ok := takeEventually(t, pump, 2)
@@ -592,14 +594,14 @@ func TestSSHCarriageBlockedConsumerSiblingProgress(t *testing.T) {
592594 stalled := mustStatus(t, pump.Engine(), 1)
593595 require.Equal(t, StreamTerminal, stalled.State)
594596 require.ErrorIs(t, stalled.Err, ErrStreamQueueFull)
595- require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind)
597+ require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind)
596598 require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State)
597599 require.False(t, pump.Engine().Closed())
598600 require.False(t, channelClosed(pump.Done()))
599601
600602 reset := decodeReset(t, nextSSHServerFrame(t, client), DirectionServer)
601603 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)
603605 }
604606
605607 // 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 == MaxMuxStreamQueueChunks582+ return ok && status.QueuedChunks == full
581 })583 })
582 584
583- // One more chunk overflows stream 1's own bound; a sibling chunk still585+ // 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 one607 // 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) {
256256 require.NoError(t, pump.Engine().Opened(Opened{Ref: testRef(PhysicalStreamID(i))}))
257257 }
258258
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})
262264 }
263265 requireEngineEventually(t, pump, func(e *StreamEngine) bool {
264266 status, ok := e.Status(1)
265- return ok && status.QueuedChunks == MaxMuxStreamQueueChunks
267+ return ok && status.QueuedChunks == full
266268 })
267269
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})
271273 injectUnixClientFrame(t, client, Data{Physical: 2, Data: []byte("sibling")})
272274
273275 taken, ok := takeEventually(t, pump, 2)
@@ -277,14 +279,14 @@ func TestUnixCarriageBlockedConsumerSiblingProgress(t *testing.T) {
277279 stalled := mustStatus(t, pump.Engine(), 1)
278280 require.Equal(t, StreamTerminal, stalled.State)
279281 require.ErrorIs(t, stalled.Err, ErrStreamQueueFull)
280- require.Equal(t, domain.RemoteFailureTransport, stalled.FailureKind)
282+ require.Equal(t, domain.RemoteFailureInvalidResponse, stalled.FailureKind)
281283 require.Equal(t, StreamOpen, mustStatus(t, pump.Engine(), 2).State)
282284 require.False(t, pump.Engine().Closed())
283285 require.False(t, channelClosed(pump.Done()))
284286
285287 reset := decodeReset(t, nextUnixServerFrame(t, client), DirectionServer)
286288 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)
288290 }
289291
290292 // 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 == MaxMuxStreamQueueChunks267+ return ok && status.QueuedChunks == full
266 })268 })
267 269
268- // One more chunk overflows stream 1's own bound; a sibling chunk still270+ // 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 one292 // 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
22
33 package ipc
44
@@ -1,4 +1,4 @@
1-//go:build !linux1+//go:build !linux && !darwin
2 2
3 package ipc3 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
22
33 package ipc
44
55 import "net"
66
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
99 // carriage never admits an unverified peer: a caller sees ErrMuxUnsupported
1010 // rather than a same-user result inferred from filesystem permissions or an
1111 // X11-style trust assumption.
@@ -1,11 +1,11 @@
1-//go:build !linux1+//go:build !linux && !darwin
2 2
3 package ipc3 package ipc
4 4
5 import "net"5 import "net"
6 6
7-// verifySameUserUnixPeer fails closed on a platform or build without a Linux7+// verifySameUserUnixPeer fails closed on a platform or build without a
8-// SO_PEERCRED same-user credential check wired into this adapter. The private8+// same-user credential check wired into this adapter. The private
9 // carriage never admits an unverified peer: a caller sees ErrMuxUnsupported9 // carriage never admits an unverified peer: a caller sees ErrMuxUnsupported
10 // rather than a same-user result inferred from filesystem permissions or an10 // 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) {
6565 // The AF_UNIX pathname limit is about 103 bytes, and t.TempDir embeds the full
6666 // test name; the broker socket lives several components beneath the sandbox
6767 // 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.
6970 func shortTempDir(t *testing.T, prefix string) string {
7071 t.Helper()
71- dir, err := os.MkdirTemp("", prefix)
72+ dir, err := os.MkdirTemp("/tmp", prefix)
7273 require.NoError(t, err)
7374 t.Cleanup(func() { _ = os.RemoveAll(dir) })
7475 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 full65 // 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 sandbox66 // 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 keeps67 // 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 dir75 return dir
M internal/app/broker_offline_test.go +1 -1
@@ -457,7 +457,7 @@ type brokerMuxFixture struct {
457457 // Pings on every admitted logical stream.
458458 func newBrokerMuxFixture(t *testing.T, policy ports.BrokerPolicy) *brokerMuxFixture {
459459 t.Helper()
460- routeDir := t.TempDir()
460+ routeDir := shortTempDir(t, "mux")
461461 require.NoError(t, os.Chmod(routeDir, 0o700))
462462 return newBrokerMuxFixtureAt(t, policy, filepath.Join(routeDir, "mux.sock"))
463463 }
@@ -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) {
886886 regular := filepath.Join(dir, "not-a-socket")
887887 require.NoError(t, os.WriteFile(regular, []byte("x"), 0o600))
888888
889- socketDir := t.TempDir()
889+ socketDir := shortTempDir(t, "ready")
890890 socketPath := filepath.Join(socketDir, "broker.sock")
891891 listener, err := net.Listen("unix", socketPath)
892892 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 (
99 )
1010
1111 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"}
1313 }
1414
1515 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 (
1616 // daemonmux physical preamble conversation (multiplex.proto), whose
1717 // negotiated ceilings and accepted daemon binding ride the same wire
1818 // version as the session and broker conversations.
19-const Version uint16 = 59
19+const Version uint16 = 60
2020
2121 // HandshakeTimeout bounds every transport handshake from connect through the
2222 // first committed publication. It excludes the preceding client-local
@@ -16,7 +16,7 @@ import (
16 // daemonmux physical preamble conversation (multiplex.proto), whose16 // daemonmux physical preamble conversation (multiplex.proto), whose
17 // negotiated ceilings and accepted daemon binding ride the same wire17 // 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 = 5919+const Version uint16 = 60
20 20
21 // HandshakeTimeout bounds every transport handshake from connect through the21 // HandshakeTimeout bounds every transport handshake from connect through the
22 // first committed publication. It excludes the preceding client-local22 // first committed publication. It excludes the preceding client-local
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(59), Version)
13+ require.Equal(t, uint16(60), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
@@ -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 (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 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)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -15,8 +15,8 @@ import (
15 // shares a payload type except route navigation failure, and the preamble15 // 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 (
1717
1818 // muxClientTags is the frozen client tag inventory.
1919 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,
2121 }
2222
2323 // muxServerTags is the frozen server tag inventory.
2424 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,
2626 }
2727
2828 func muxStreamRef() *MuxStreamRef {
@@ -59,20 +59,22 @@ func muxClientSamples() map[string]*MuxClientEnvelope {
5959 Env: []string{"TERM=xterm-256color", "LANG=C.UTF-8"},
6060 Policy: muxPolicy(),
6161 }}},
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}}},
6566 }
6667 }
6768
6869 // muxServerSamples returns one populated envelope per server variant.
6970 func muxServerSamples() map[string]*MuxServerEnvelope {
7071 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}}},
7678 }
7779 }
7880
@@ -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 @@
44 //
55 // daemonmux is a third conversation, separate from the session protocol
66 // (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
88 // tag, and each direction is its own closed oneof union. The physical
99 // preamble is a separate adapter message pair, not an envelope variant:
1010 // roles 5/6 negotiate the per-connection ceilings, the authenticated daemon
1111 // identity, and the accepted policy once, before any application message.
1212 //
13-// MuxData, MuxClose, and MuxReset are shared payloads carried in both
13+// MuxData, MuxClose, MuxReset, and MuxWindowUpdate are shared payloads carried in both
1414 // directions; MuxOpen carries the full stream identity, and every later
1515 // frame is routed by physical_stream_id alone.
1616 syntax = "proto3";
@@ -95,6 +95,18 @@ message MuxReset {
9595 BrokerErrorDetail error = 2;
9696 }
9797
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+
98110 // MuxClientEnvelope is the closed broker-to-daemon mux union. Exactly one
99111 // variant must be set per envelope; empty, duplicate, and wrong-direction
100112 // payloads are rejected before generated unmarshal.
@@ -104,6 +116,7 @@ message MuxClientEnvelope {
104116 MuxData data = 302;
105117 MuxClose close = 303;
106118 MuxReset reset = 304;
119+ MuxWindowUpdate window_update = 305;
107120 }
108121 }
109122
@@ -116,6 +129,7 @@ message MuxServerEnvelope {
116129 MuxData data = 403;
117130 MuxClose close = 404;
118131 MuxReset reset = 405;
132+ MuxWindowUpdate window_update = 406;
119133 }
120134 }
121135
@@ -4,13 +4,13 @@
4 //4 //
5 // daemonmux is a third conversation, separate from the session protocol5 // 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 other7+// 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 physical8 // 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 daemon10 // 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 both13+// MuxData, MuxClose, MuxReset, and MuxWindowUpdate are shared payloads carried in both
14 // directions; MuxOpen carries the full stream identity, and every later14 // 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 one110 // MuxClientEnvelope is the closed broker-to-daemon mux union. Exactly one
99 // variant must be set per envelope; empty, duplicate, and wrong-direction111 // 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 (
44 "context"
55 "encoding/binary"
66 "errors"
7+ "log/slog"
78 "sync"
89 "time"
910
@@ -64,11 +65,27 @@ type reservation struct {
6465 cancel context.CancelFunc
6566 entry *poolEntry
6667 }
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+
6784 type poolEntry struct {
6885 key poolKey
6986 pending pendingKey
7087 ctx context.Context
71- cancel context.CancelFunc
88+ cancel context.CancelCauseFunc
7289 ready, wake chan struct{}
7390 physical ports.BrokerPhysicalConnection
7491 redirect *poolEntry
@@ -200,8 +217,8 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest
200217 p.mu.Lock()
201218 delete(c.streams, req.Stream)
202219 p.streams--
203- var abandon context.CancelFunc
204- var evicted []context.CancelFunc
220+ var abandon func()
221+ var evicted []func()
205222 reservedEntry := r.entry
206223 if reservedEntry != nil {
207224 reservedEntry.refs--
@@ -211,7 +228,7 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest
211228 evicted = p.idleLocked(reservedEntry)
212229 default:
213230 reservedEntry.retiring = true
214- abandon = reservedEntry.cancel
231+ abandon = reservedEntry.retire(errEntryAbandoned)
215232 }
216233 }
217234 select {
@@ -269,7 +286,7 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest
269286 p.mu.Unlock()
270287 return nil, ports.BrokerAdmissionLimit
271288 }
272- ectx, ecancel := context.WithCancel(p.ctx)
289+ ectx, ecancel := context.WithCancelCause(p.ctx)
273290 entry = &poolEntry{key: key, pending: pending, ctx: ectx, cancel: ecancel, ready: make(chan struct{}), wake: make(chan struct{}, 1)}
274291 if resolved.ExpectedIdentity.Bound {
275292 p.entries[key] = entry
@@ -332,7 +349,6 @@ func (p *Pool) OpenStream(ctx context.Context, req ports.BrokerOpenStreamRequest
332349 case <-ctx.Done():
333350 terminal = ports.BrokerError{Code: ports.BrokerErrorCancelled}
334351 case <-entry.ctx.Done():
335- terminal = ports.BrokerError{Code: ports.BrokerErrorAttachmentLost}
336352 case <-entry.physical.Done():
337353 case <-raw.Done():
338354 terminal = raw.Err()
@@ -394,7 +410,7 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) {
394410 p.mu.Unlock()
395411 if redirect != nil {
396412 _ = physical.Close()
397- e.cancel()
413+ e.cancel(errEntryRedirect)
398414 return
399415 }
400416 if err == nil {
@@ -421,6 +437,7 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) {
421437 case <-e.ctx.Done():
422438 goto retire
423439 case <-physical.Done():
440+ e.cancel(errEntryLost)
424441 goto retire
425442 case <-e.wake:
426443 continue
@@ -429,6 +446,7 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) {
429446 if e.refs == 0 {
430447 e.retiring = true
431448 p.mu.Unlock()
449+ e.cancel(errEntryIdle)
432450 goto retire
433451 }
434452 p.mu.Unlock()
@@ -438,9 +456,15 @@ func (p *Pool) runEntry(e *poolEntry, endpoint ports.BrokerDialTarget) {
438456 retire:
439457 p.mu.Lock()
440458 e.retiring = true
459+ refs := e.refs
441460 p.mu.Unlock()
442- e.cancel()
461+ if err != nil {
462+ e.cancel(err)
463+ } else {
464+ e.cancel(errEntryLost)
465+ }
443466 if physical != nil {
467+ p.logRetire(e, physical, refs)
444468 _ = physical.Close()
445469 }
446470 // Keep the key occupied until Close completes: never overlap physicals.
@@ -458,7 +482,7 @@ retire:
458482 // and returns the cancellations that retire warm entries beyond limits.Warm,
459483 // least recently idled first. The caller runs them after unlocking. An entry
460484 // 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() {
462486 if e.err != nil || e.redirect != nil || e.retiring || p.entries[e.key] != e {
463487 return nil
464488 }
@@ -470,7 +494,7 @@ func (p *Pool) idleLocked(e *poolEntry) []context.CancelFunc {
470494 warm = append(warm, candidate)
471495 }
472496 }
473- var evicted []context.CancelFunc
497+ var evicted []func()
474498 for len(warm) > p.limits.Warm {
475499 oldest := 0
476500 for i := range warm {
@@ -480,7 +504,7 @@ func (p *Pool) idleLocked(e *poolEntry) []context.CancelFunc {
480504 }
481505 victim := warm[oldest]
482506 victim.retiring = true
483- evicted = append(evicted, victim.cancel)
507+ evicted = append(evicted, victim.retire(errEntryEvicted))
484508 warm = append(warm[:oldest], warm[oldest+1:]...)
485509 }
486510 return evicted
@@ -505,7 +529,7 @@ func (p *Pool) AdoptPhysical(physical ports.BrokerPhysicalConnection, policy por
505529 p.mu.Unlock()
506530 return false
507531 }
508- ctx, cancel := context.WithCancel(p.ctx)
532+ ctx, cancel := context.WithCancelCause(p.ctx)
509533 e := &poolEntry{key: key, ctx: ctx, cancel: cancel, ready: make(chan struct{}), wake: make(chan struct{}, 1), physical: physical}
510534 close(e.ready)
511535 p.entries[key] = e
@@ -543,6 +567,7 @@ func (p *Pool) watchAdopted(e *poolEntry) {
543567 case <-e.ctx.Done():
544568 goto retire
545569 case <-physical.Done():
570+ e.cancel(errEntryLost)
546571 goto retire
547572 case <-e.wake:
548573 continue
@@ -551,6 +576,7 @@ func (p *Pool) watchAdopted(e *poolEntry) {
551576 if e.refs == 0 && e.idleOrder != 0 {
552577 e.retiring = true
553578 p.mu.Unlock()
579+ e.cancel(errEntryIdle)
554580 goto retire
555581 }
556582 p.mu.Unlock()
@@ -559,8 +585,10 @@ func (p *Pool) watchAdopted(e *poolEntry) {
559585 retire:
560586 p.mu.Lock()
561587 e.retiring = true
588+ refs := e.refs
562589 p.mu.Unlock()
563- e.cancel()
590+ e.cancel(errEntryLost)
591+ p.logRetire(e, physical, refs)
564592 _ = physical.Close()
565593 p.mu.Lock()
566594 if p.entries[e.key] == e {
@@ -597,6 +625,26 @@ func (p *Pool) SharedPhysical(identity ports.BrokerDaemonIdentity, policy ports.
597625 return e.physical, true
598626 }
599627
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+
600648 // Close cancels pending operations, closes transports, and joins all owned
601649 // workers. Bounded completion depends on the explicit cancellation/Close port
602650 // 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
682730 default:
683731 }
684732 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}
686735 }
687736 return normalizePoolError(fallback)
688737 }
@@ -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.CancelFunc65 cancel context.CancelFunc
65 entry *poolEntry66 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 poolKey85 key poolKey
69 pending pendingKey86 pending pendingKey
70 ctx context.Context87 ctx context.Context
71- cancel context.CancelFunc88+ cancel context.CancelCauseFunc
72 ready, wake chan struct{}89 ready, wake chan struct{}
73 physical ports.BrokerPhysicalConnection90 physical ports.BrokerPhysicalConnection
74 redirect *poolEntry91 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.CancelFunc220+ var abandon func()
204- var evicted []context.CancelFunc221+ var evicted []func()
205 reservedEntry := r.entry222 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 = true230 reservedEntry.retiring = true
214- abandon = reservedEntry.cancel231+ 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.BrokerAdmissionLimit287 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] = entry292 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 return414 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 retire438 goto retire
423 case <-physical.Done():439 case <-physical.Done():
440+ e.cancel(errEntryLost)
424 goto retire441 goto retire
425 case <-e.wake:442 case <-e.wake:
426 continue443 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 = true447 e.retiring = true
431 p.mu.Unlock()448 p.mu.Unlock()
449+ e.cancel(errEntryIdle)
432 goto retire450 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 = true458 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 entry483 // 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 nil487 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.CancelFunc497+ var evicted []func()
474 for len(warm) > p.limits.Warm {498 for len(warm) > p.limits.Warm {
475 oldest := 0499 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 = true506 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 evicted510 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 false530 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] = e535 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 retire568 goto retire
545 case <-physical.Done():569 case <-physical.Done():
570+ e.cancel(errEntryLost)
546 goto retire571 goto retire
547 case <-e.wake:572 case <-e.wake:
548 continue573 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 = true577 e.retiring = true
553 p.mu.Unlock()578 p.mu.Unlock()
579+ e.cancel(errEntryIdle)
554 goto retire580 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 = true587 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, true625 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 owned648 // Close cancels pending operations, closes transports, and joins all owned
601 // workers. Bounded completion depends on the explicit cancellation/Close port649 // 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) {
171171 }
172172 }
173173
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+
174205 // Reset deliberately does not drain, matching older buffered timer adapters.
175206 type bufferedPoolTimer struct {
176207 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.Time207 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 {
581581 return cancel
582582 }
583583
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+
584589 func receiveCall(t *testing.T, probe *testProbe) probeCall {
585590 t.Helper()
586591 select {
587592 case call := <-probe.calls:
588593 return call
589- case <-time.After(time.Second):
594+ case <-time.After(registryTestWait):
590595 t.Fatal("timed out waiting for probe")
591596 return probeCall{}
592597 }
@@ -607,7 +612,7 @@ func waitSnapshot(t *testing.T, r *Registry, predicate func(ports.BrokerSnapshot
607612 t.Helper()
608613 sub := r.Subscribe()
609614 defer sub.Close()
610- deadline := time.After(time.Second)
615+ deadline := time.After(registryTestWait)
611616 for {
612617 snapshot := r.Snapshot()
613618 if predicate(snapshot) {
@@ -581,12 +581,17 @@ func startRegistry(t *testing.T, r *Registry) context.CancelFunc {
581 return cancel581 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 call593 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{} {
915915 return f.repaint
916916 }
917917
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+
918930 func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) {
919931 if f == nil {
920932 return
@@ -915,6 +915,18 @@ func (f *attachmentForeground) overlayRepaint() <-chan struct{} {
915 return f.repaint915 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 return932 return
M internal/usecase/client/lifecycle_p54a_test.go +2 -4
@@ -2,7 +2,6 @@ package client
22
33 import (
44 "context"
5- "errors"
65 "sync"
76 "testing"
87 "time"
@@ -64,9 +63,8 @@ func TestP54AttachmentOutcomesReturnToPicker(t *testing.T) {
6463 // the client instead of returning to the picker.
6564 {name: "server shutdown", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonServerShutdown}) }, notice: LifecycleNoticeSessionEnded},
6665 {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.
7068 } {
7169 t.Run(tc.name, func(t *testing.T) {
7270 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 {
442442 // target while an attachment was live. It is only touched from the run
443443 // goroutine and consumed by runResolvedAttachment.
444444 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
445456 // kills runs the picker's `x` operations off the run goroutine.
446457 kills pickerKills
447458 // 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 run442 // 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 *pickerAttachmentTarget444 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 pickerKills457 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 {
3636 return ports.BrokerError{Code: ports.BrokerErrorTimeout, Text: "attachment timed out", Cause: cause}
3737 }
3838
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+
3943 // attachmentDeadline owns the one absolute deadline of a whole attachment. It
4044 // is safe for concurrent use: its timer goroutine expires it while the
4145 // 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
550554 picker.SetOwnsInput(true)
551555 input.acquirePicker()
552556 }()
557+ defer func() { s.resuming, s.resumeErr, s.resume = false, nil, nil }()
558+ resumes := 0
553559 for {
560+ s.resumeErr = nil
561+ attemptStart := s.cfg.Clock.Now()
554562 terminated, termErr, swap := s.attachOnce(ctx, input, service, target, localProvenance)
555- if terminated || swap == nil {
563+ if terminated {
556564 return terminated, termErr
557565 }
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
558615 // A commit from the picker overlay to another target: the previous
559616 // attachment detached cleanly, and the swap goes through Connecting
560617 // exactly like any committed selection. The stream identity is
@@ -642,6 +699,13 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
642699 // Another foreground owns the terminal, or the shared reader is
643700 // claimed. Begin never closes the stream, so the supervisor does.
644701 _ = 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+ }
645709 s.transition(supervisorEvent{kind: supervisorAttachEnded})
646710 return false, nil
647711 }
@@ -739,6 +803,9 @@ settlement:
739803 }
740804 if deadline.TimedOut() && !result.adopted {
741805 timeout := attachmentTimeoutError(errAttachmentDeadline)
806+ if s.deferResumeFailure(timeout) {
807+ return false, nil
808+ }
742809 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout})
743810 s.notifyAttachment(timeout)
744811 return false, nil
@@ -755,8 +822,25 @@ settlement:
755822 s.notifyLifecycle(LifecycleNoticeDetachToPicker)
756823 return false, nil
757824 }
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+ }
758836 if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) {
759837 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+ }
760844 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err})
761845 s.notifyLifecycle(LifecycleNoticeDestinationFailed)
762846 s.notifyAttachment(result.event.Err)
@@ -774,8 +858,96 @@ settlement:
774858 return false, nil
775859 }
776860
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.
778947 func (s *Supervisor) reportAttachmentFailure(err error) {
948+ if s.deferResumeFailure(err) {
949+ return
950+ }
779951 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err})
780952 // Classification order matters: a local refusal never dialed a destination,
781953 // 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. It43 // attachmentDeadline owns the one absolute deadline of a whole attachment. It
40 // is safe for concurrent use: its timer goroutine expires it while the44 // 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, termErr564 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 previous615 // A commit from the picker overlay to another target: the previous
559 // attachment detached cleanly, and the swap goes through Connecting616 // attachment detached cleanly, and the swap goes through Connecting
560 // exactly like any committed selection. The stream identity is617 // 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 is699 // 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, nil710 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, nil811 return false, nil
@@ -755,8 +822,25 @@ settlement:
755 s.notifyLifecycle(LifecycleNoticeDetachToPicker)822 s.notifyLifecycle(LifecycleNoticeDetachToPicker)
756 return false, nil823 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, nil858 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 outcome953 // 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 (
77 "io"
88 "strings"
99 "sync"
10+ "sync/atomic"
1011 "testing"
1112 "time"
1213
@@ -1288,42 +1289,158 @@ func TestAttachmentDeadlineConcurrentExpiryAndRetirement(t *testing.T) {
12881289 }
12891290 }
12901291
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+}
13101296
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 }
13161301
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+ }
13271444 }
13281445
13291446 // 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 stream1292+// testStreamLoss is a physical loss of the stream carrying an attachment.
1292-// that fails after attachment never keeps the attached presentation: the1293+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 := stream1298+// 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 cancellation1446 // TestSupervisorAttachmentCancellationRetiresGeneration proves cancellation
M internal/usecase/daemon/client_frame_routing.go +9 -0
@@ -2,6 +2,8 @@ package daemon
22
33 import (
44 "errors"
5+ "io"
6+ "net"
57 "runtime"
68
79 "github.com/bnema/vev/internal/domain"
@@ -63,6 +65,13 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
6365 )
6466 continue
6567 }
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+ }
6675 for range connectionSnapshotAttempts {
6776 sess, _, ok := d.currentAttachmentConnection(ac, tr)
6877 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 continue66 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) {
619619 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
620620 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
621621 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}}
623626 ac.setSession(sess)
624627
625628 // 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-bar628 // Client rows are one more than the equivalent case in a single-bar

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close