Repository: quic-go/quic-go
Pull request #5118 (2025-05-05 00:28:29)

ci: bump golangci-lint-action to v8 and golangci-lint to v2.1.6
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 7 to 8.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/golangci/golangci-lint-action/releases">golangci/golangci-lint-action's releases</a>.</em></p>
<blockquote>
<h2>v8.0.0</h2>
<p><strong>Requires <code>golangci-lint</code> version &gt;= <code>v2.1.0</code></strong></p>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>Changes</h3>
<ul>
<li>feat: use absolute paths by default when using working-directory option by <a href="https://github.com/ldez"><code>@​ldez</code></a> in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1231">golangci/golangci-lint-action#1231</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/golangci/golangci-lint-action/compare/v7...v8.0.0">https://github.com/golangci/golangci-lint-action/compare/v7...v8.0.0</a></p>
<h2>v7.0.1</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>Documentation</h3>
<ul>
<li>docs: add note about github.workspace by <a href="https://github.com/mattjohnsonpint"><code>@​mattjohnsonpint</code></a> in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1218">golangci/golangci-lint-action#1218</a></li>
<li>docs: clarify that ’args: --path-mode=abs’ is needed for working-directory by <a href="https://github.com/HaraldNordgren"><code>@​HaraldNordgren</code></a> in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1230">golangci/golangci-lint-action#1230</a></li>
</ul>
<h3>Dependencies</h3>
<ul>
<li>build(deps): bump the dependencies group across 1 directory with 3 updates by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1213">golangci/golangci-lint-action#1213</a></li>
<li>build(deps-dev): bump the dev-dependencies group with 3 updates by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1215">golangci/golangci-lint-action#1215</a></li>
<li>build(deps-dev): bump the dev-dependencies group with 4 updates by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1220">golangci/golangci-lint-action#1220</a></li>
<li>build(deps): bump <code>@​types/node</code> from 22.13.14 to 22.14.0 in the dependencies group by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1221">golangci/golangci-lint-action#1221</a></li>
<li>build(deps-dev): bump the dev-dependencies group with 3 updates by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1224">golangci/golangci-lint-action#1224</a></li>
<li>build(deps): bump <code>@​types/node</code> from 22.14.0 to 22.14.1 in the dependencies group by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1225">golangci/golangci-lint-action#1225</a></li>
<li>build(deps-dev): bump the dev-dependencies group with 2 updates by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1227">golangci/golangci-lint-action#1227</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/mattjohnsonpint"><code>@​mattjohnsonpint</code></a> made their first contribution in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1218">golangci/golangci-lint-action#1218</a></li>
<li><a href="https://github.com/HaraldNordgren"><code>@​HaraldNordgren</code></a> made their first contribution in <a href="https://redirect.github.com/golangci/golangci-lint-action/pull/1230">golangci/golangci-lint-action#1230</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/golangci/golangci-lint-action/compare/v7.0.0...v7.0.1">https://github.com/golangci/golangci-lint-action/compare/v7.0.0...v7.0.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/golangci/golangci-lint-action/commit/4afd733a84b1f43292c63897423277bb7f4313a9"><code>4afd733</code></a> 8.0.0</li>
<li><a href="https://github.com/golangci/golangci-lint-action/commit/7774f98a88e7cf61e429af992e73272d64787bac"><code>7774f98</code></a> feat: use absolute paths by default when using working-directory option (<a href="https://redirect.github.com/golangci/golangci-lint-action/issues/1231">#1231</a>)</li>
<li>See full diff in <a href="https://github.com/golangci/golangci-lint-action/compare/v7...v8">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golangci/golangci-lint-action&package-manager=github_actions&previous-version=7&new-version=8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index b8b00f0b..2ce5cf57 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -41,7 +41,7 @@ jobs:
       matrix:
         go: [  "1.23.x", "1.24.x" ]
     env:
-      GOLANGCI_LINT_VERSION: v2.0.2
+      GOLANGCI_LINT_VERSION: v2.1.6
     name: golangci-lint (Go ${{ matrix.go }})
     steps:
       - uses: actions/checkout@v4
@@ -49,13 +49,13 @@ jobs:
         with:
           go-version: ${{ matrix.go }}
       - name: golangci-lint (Linux)
-        uses: golangci/golangci-lint-action@v7
+        uses: golangci/golangci-lint-action@v8
         with:
           args: --timeout=3m
           version: ${{ env.GOLANGCI_LINT_VERSION }}
       - name: golangci-lint (Windows)
         if: success() || failure() # run this step even if the previous one failed
-        uses: golangci/golangci-lint-action@v7
+        uses: golangci/golangci-lint-action@v8
         env:
           GOOS: "windows"
         with:
@@ -63,7 +63,7 @@ jobs:
           version: ${{ env.GOLANGCI_LINT_VERSION }}
       - name: golangci-lint (OSX)
         if: success() || failure() # run this step even if the previous one failed
-        uses: golangci/golangci-lint-action@v7
+        uses: golangci/golangci-lint-action@v8
         env:
           GOOS: "darwin"
         with:
@@ -71,7 +71,7 @@ jobs:
           version: ${{ env.GOLANGCI_LINT_VERSION }}
       - name: golangci-lint (FreeBSD)
         if: success() || failure() # run this step even if the previous one failed
-        uses: golangci/golangci-lint-action@v7
+        uses: golangci/golangci-lint-action@v8
         env:
           GOOS: "freebsd"
         with:
@@ -79,7 +79,7 @@ jobs:
           version: ${{ env.GOLANGCI_LINT_VERSION }}
       - name: golangci-lint (others)
         if: success() || failure() # run this step even if the previous one failed
-        uses: golangci/golangci-lint-action@v7
+        uses: golangci/golangci-lint-action@v8
         env:
           GOOS: "solaris" # some OS that we don't have any build tags for
         with:
diff --git a/http3/server.go b/http3/server.go
index 88c255a9..10f3f80a 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -450,19 +450,19 @@ func (s *Server) handleConn(conn quic.Connection) error {
 	}).Append(b)
 	ctrlStr.Write(b)
 
-	ctx := conn.Context()
-	ctx = context.WithValue(ctx, ServerContextKey, s)
-	ctx = context.WithValue(ctx, http.LocalAddrContextKey, conn.LocalAddr())
-	ctx = context.WithValue(ctx, RemoteAddrContextKey, conn.RemoteAddr())
+	connCtx := conn.Context()
+	connCtx = context.WithValue(connCtx, ServerContextKey, s)
+	connCtx = context.WithValue(connCtx, http.LocalAddrContextKey, conn.LocalAddr())
+	connCtx = context.WithValue(connCtx, RemoteAddrContextKey, conn.RemoteAddr())
 	if s.ConnContext != nil {
-		ctx = s.ConnContext(ctx, conn)
-		if ctx == nil {
+		connCtx = s.ConnContext(connCtx, conn)
+		if connCtx == nil {
 			panic("http3: ConnContext returned nil")
 		}
 	}
 
 	hconn := newConnection(
-		ctx,
+		connCtx,
 		conn,
 		s.EnableDatagrams,
 		protocol.PerspectiveServer,
@@ -474,46 +474,57 @@ func (s *Server) handleConn(conn quic.Connection) error {
 	var nextStreamID quic.StreamID
 	var wg sync.WaitGroup
 	var handleErr error
+	var inGracefulShutdown bool
 	// Process all requests immediately.
 	// It's the client's responsibility to decide which requests are eligible for 0-RTT.
+	ctx := s.graceCtx
 	for {
-		str, datagrams, err := hconn.acceptStream(s.graceCtx)
+		// The context used here is:
+		// * before graceful shutdown: s.graceCtx
+		// * after graceful shutdown: s.closeCtx
+		// This allows us to keep accepting (and resetting) streams after graceful shutdown has started.
+		str, datagrams, err := hconn.acceptStream(ctx)
 		if err != nil {
+			// the underlying connection was closed (by either side)
+			if hconn.Context().Err() != nil {
+				var appErr *quic.ApplicationError
+				if !errors.As(err, &appErr) || appErr.ErrorCode != quic.ApplicationErrorCode(ErrCodeNoError) {
+					handleErr = fmt.Errorf("accepting stream failed: %w", err)
+				}
+				break
+			}
 			// server (not gracefully) closed, close the connection immediately
 			if s.closeCtx.Err() != nil {
 				conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeNoError), "")
 				handleErr = http.ErrServerClosed
 				break
 			}
-
-			// gracefully closed, send GOAWAY frame and wait for requests to complete or grace period to end
-			// new requests will be rejected and shouldn't be sent
-			if s.graceCtx.Err() != nil {
-				wg.Add(1)
-				// Send the GOAWAY frame in a separate Goroutine.
-				// Sending might block if the peer didn't grant enough flow control credit.
-				// Write is guaranteed to return once the connection is closed.
-				go func() {
-					defer wg.Done()
-					_, _ = ctrlStr.Write((&goAwayFrame{StreamID: nextStreamID}).Append(nil))
-				}()
-
-				select {
-				case <-hconn.Context().Done():
-					// we expect the client to eventually close the connection after receiving the GOAWAY
-				case <-s.closeCtx.Done():
-					// close the connection after graceful period
-					conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeNoError), "")
+			inGracefulShutdown = s.graceCtx.Err() != nil
+			if !inGracefulShutdown {
+				var appErr *quic.ApplicationError
+				if !errors.As(err, &appErr) || appErr.ErrorCode != quic.ApplicationErrorCode(ErrCodeNoError) {
+					handleErr = fmt.Errorf("accepting stream failed: %w", err)
 				}
-				handleErr = http.ErrServerClosed
 				break
 			}
 
-			var appErr *quic.ApplicationError
-			if !errors.As(err, &appErr) || appErr.ErrorCode != quic.ApplicationErrorCode(ErrCodeNoError) {
-				handleErr = fmt.Errorf("accepting stream failed: %w", err)
-			}
-			break
+			// gracefully closed, send GOAWAY frame and wait for requests to complete or grace period to end
+			// new requests will be rejected and shouldn't be sent
+			wg.Add(1)
+			// Send the GOAWAY frame in a separate Goroutine.
+			// Sending might block if the peer didn't grant enough flow control credit.
+			// Write is guaranteed to return once the connection is closed.
+			go func() {
+				defer wg.Done()
+				_, _ = ctrlStr.Write((&goAwayFrame{StreamID: nextStreamID}).Append(nil))
+			}()
+			ctx = s.closeCtx
+			continue
+		}
+		if inGracefulShutdown {
+			str.CancelRead(quic.StreamErrorCode(ErrCodeRequestRejected))
+			str.CancelWrite(quic.StreamErrorCode(ErrCodeRequestRejected))
+			continue
 		}
 
 		nextStreamID = str.StreamID() + 4
@@ -690,9 +701,10 @@ func (s *Server) Close() error {
 	return err
 }
 
-// Shutdown shuts down the server gracefully.
+// Shutdown gracefully shuts down the server without interrupting any active connections.
 // The server sends a GOAWAY frame first, then or for all running requests to complete.
-// Shutdown in combination with ListenAndServe() (instead of Serve()) may race if it is called before a UDP socket is established.
+// Shutdown in combination with ListenAndServe may race if it is called before a UDP socket is established.
+// It is recommended to use Serve instead.
 func (s *Server) Shutdown(ctx context.Context) error {
 	s.mutex.Lock()
 	s.closed = true


Test patch:
diff --git a/http3/server_test.go b/http3/server_test.go
index 1e33061a..b27d85f5 100644
--- a/http3/server_test.go
+++ b/http3/server_test.go
@@ -860,7 +860,7 @@ func TestServerClosing(t *testing.T) {
 	require.ErrorIs(t, s.ServeQUICConn(nil), http.ErrServerClosed)
 }
 
-func TestHandlesConcurrentServeAndClose(t *testing.T) {
+func TestServerConcurrentServeAndClose(t *testing.T) {
 	addr, err := net.ResolveUDPAddr("udp", "localhost:0")
 	require.NoError(t, err)
 	c, err := net.ListenUDP("udp", addr)
@@ -879,3 +879,134 @@ func TestHandlesConcurrentServeAndClose(t *testing.T) {
 		t.Fatal("timeout")
 	}
 }
+
+func TestServerGracefulShutdown(t *testing.T) {
+	s := &Server{TLSConfig: testdata.GetTLSConfig()}
+	s.init()
+
+	mockCtrl := gomock.NewController(t)
+	controlStr := mockquic.NewMockStream(mockCtrl)
+	controlStrChan := make(chan []byte, 1)
+	controlStr.EXPECT().Write(gomock.Any()).DoAndReturn(func(b []byte) (int, error) {
+		controlStrChan <- b
+		return len(b), nil
+	}).AnyTimes()
+
+	streamChan := make(chan quic.Stream, 1)
+	testDone := make(chan struct{})
+	conn := mockquic.NewMockEarlyConnection(mockCtrl)
+	conn.EXPECT().OpenUniStream().Return(controlStr, nil)
+	conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+		<-testDone
+		return nil, assert.AnError
+	}).MaxTimes(1)
+	conn.EXPECT().AcceptStream(gomock.Any()).DoAndReturn(func(ctx context.Context) (quic.Stream, error) {
+		select {
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		case str, ok := <-streamChan:
+			if !ok {
+				return nil, assert.AnError
+			}
+			return str, nil
+		}
+	}).AnyTimes()
+	conn.EXPECT().RemoteAddr().Return(&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1337}).AnyTimes()
+	conn.EXPECT().LocalAddr().AnyTimes()
+	conn.EXPECT().Context().Return(context.Background()).AnyTimes()
+
+	firstStream := mockquic.NewMockStream(mockCtrl)
+	firstStreamAccepted := make(chan struct{}, 1)
+	firstStream.EXPECT().Read(gomock.Any()).DoAndReturn(func(b []byte) (int, error) {
+		firstStreamAccepted <- struct{}{}
+		<-testDone
+		return 0, assert.AnError
+	})
+	firstStream.EXPECT().StreamID().Return(quic.StreamID(1337)).AnyTimes()
+	firstStream.EXPECT().Context().Return(context.Background()).AnyTimes()
+	streamChan <- firstStream
+
+	go s.ServeQUICConn(conn)
+
+	var r bytes.Buffer
+	fp := &frameParser{r: &r}
+
+	select {
+	case b := <-controlStrChan:
+		_, l, err := quicvarint.Parse(b)
+		require.NoError(t, err)
+		r.Write(b[l:])
+		f, err := fp.ParseNext()
+		require.NoError(t, err)
+		require.IsType(t, &settingsFrame{}, f)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+
+	select {
+	case <-firstStreamAccepted:
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+
+	time.Sleep(scaleDuration(10 * time.Millisecond))
+
+	ctx, cancel := context.WithCancel(context.Background())
+	errChan := make(chan error)
+	go func() {
+		errChan <- s.Shutdown(ctx)
+	}()
+
+	select {
+	case b := <-controlStrChan:
+		r.Write(b)
+		f, err := fp.ParseNext()
+		require.NoError(t, err)
+		require.Equal(t, &goAwayFrame{StreamID: 1337 + 4}, f)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+
+	select {
+	case <-errChan:
+		t.Fatal("didn't expect Shutdown to return")
+	case <-time.After(scaleDuration(10 * time.Millisecond)):
+	}
+
+	// all further streams are getting rejected
+	for range 3 {
+		resetChan := make(chan struct{}, 2)
+		str := mockquic.NewMockStream(mockCtrl)
+		str.EXPECT().StreamID().AnyTimes()
+		str.EXPECT().Context().Return(context.Background()).AnyTimes()
+		str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestRejected)).Do(func(sec quic.StreamErrorCode) {
+			resetChan <- struct{}{}
+		})
+		str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestRejected)).Do(func(sec quic.StreamErrorCode) {
+			resetChan <- struct{}{}
+		})
+		streamChan <- str
+
+		for range 2 {
+			select {
+			case <-resetChan:
+			case <-time.After(time.Second):
+				t.Fatal("expected stream reset")
+			}
+		}
+	}
+
+	// cancel the context passed to Shutdown
+	conn.EXPECT().CloseWithError(quic.ApplicationErrorCode(ErrCodeNoError), gomock.Any())
+	cancel()
+	firstStream.EXPECT().CancelRead(gomock.Any())
+	firstStream.EXPECT().CancelWrite(gomock.Any())
+	close(testDone)
+
+	select {
+	case err := <-errChan:
+		require.ErrorIs(t, err, context.Canceled)
+	case <-time.After(time.Second):
+		t.Fatal("timeout")
+	}
+}
diff --git a/integrationtests/self/http_shutdown_test.go b/integrationtests/self/http_shutdown_test.go
index 8d677182..fd1e00e2 100644
--- a/integrationtests/self/http_shutdown_test.go
+++ b/integrationtests/self/http_shutdown_test.go
@@ -4,11 +4,13 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"net"
 	"net/http"
 	"testing"
 	"time"
 
 	"github.com/quic-go/quic-go/http3"
+	quicproxy "github.com/quic-go/quic-go/integrationtests/tools/proxy"
 
 	"github.com/stretchr/testify/require"
 )
@@ -23,7 +25,7 @@ func TestHTTPShutdown(t *testing.T) {
 		go func() {
 			require.NoError(t, server.Close())
 		}()
-		time.Sleep(50 * time.Millisecond) // make sure the server started shutting down
+		time.Sleep(scaleDuration(10 * time.Millisecond)) // make sure the server started shutting down
 	})
 
 	_, err := client.Get(fmt.Sprintf("https://localhost:%d/shutdown", port))
@@ -34,7 +36,7 @@ func TestHTTPShutdown(t *testing.T) {
 }
 
 func TestGracefulShutdownShortRequest(t *testing.T) {
-	delay := scaleDuration(50 * time.Millisecond)
+	delay := scaleDuration(25 * time.Millisecond)
 
 	var server *http3.Server
 	mux := http.NewServeMux()
@@ -71,7 +73,7 @@ func TestGracefulShutdownShortRequest(t *testing.T) {
 }
 
 func TestGracefulShutdownLongLivedRequest(t *testing.T) {
-	delay := scaleDuration(50 * time.Millisecond)
+	delay := scaleDuration(25 * time.Millisecond)
 	errChan := make(chan error, 1)
 	requestChan := make(chan time.Duration, 1)
 
@@ -125,3 +127,72 @@ func TestGracefulShutdownLongLivedRequest(t *testing.T) {
 		t.Fatal("did not receive request duration")
 	}
 }
+
+func TestGracefulShutdownPendingStreams(t *testing.T) {
+	rtt := scaleDuration(25 * time.Millisecond)
+
+	handlerChan := make(chan struct{}, 1)
+	mux := http.NewServeMux()
+	mux.HandleFunc("/helloworld", func(w http.ResponseWriter, r *http.Request) {
+		handlerChan <- struct{}{}
+		time.Sleep(rtt)
+		w.Write([]byte("hello world"))
+	})
+	var server *http3.Server
+	port := startHTTPServer(t, mux, func(s *http3.Server) { server = s })
+	client := newHTTP3Client(t)
+
+	proxy := quicproxy.Proxy{
+		Conn:       newUDPConnLocalhost(t),
+		ServerAddr: &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port},
+		DelayPacket: func(_ quicproxy.Direction, _, _ net.Addr, data []byte) time.Duration {
+			return rtt
+		},
+	}
+	require.NoError(t, proxy.Start())
+	defer proxy.Close()
+	proxyPort := proxy.LocalAddr().(*net.UDPAddr).Port
+
+	errChan := make(chan error, 1)
+	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://localhost:%d/helloworld", proxyPort), nil)
+	require.NoError(t, err)
+	go func() {
+		resp, err := client.Do(req)
+		if err != nil {
+			errChan <- err
+			return
+		}
+		if resp.StatusCode != http.StatusOK {
+			errChan <- fmt.Errorf("expected status code %d, got %d", http.StatusOK, resp.StatusCode)
+		}
+	}()
+
+	select {
+	case <-handlerChan:
+	case <-time.After(time.Second):
+		t.Fatal("did not receive request")
+	}
+
+	shutdownChan := make(chan error, 1)
+	ctx, cancel := context.WithCancel(context.Background())
+	go func() { shutdownChan <- server.Shutdown(ctx) }()
+	time.Sleep(rtt / 2) // wait for the server to start shutting down
+
+	// make sure that the server rejects further requests
+	for range 3 {
+		req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://localhost:%d/helloworld", proxyPort), nil)
+		require.NoError(t, err)
+		_, err = client.Do(req)
+		var h3err *http3.Error
+		require.ErrorAs(t, err, &h3err)
+		require.Equal(t, http3.ErrCodeRequestRejected, h3err.ErrorCode)
+	}
+
+	cancel()
+	select {
+	case err := <-shutdownChan:
+		require.ErrorIs(t, err, context.Canceled)
+	case <-time.After(time.Second):
+		t.Fatal("shutdown did not complete")
+	}
+}