Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ jobs:
}
Write-Output "Verified ${testName}: $passes passes, $skips skips"
}
- name: Exercise bounded web listener retries and rollback on Windows
shell: pwsh
run: |
$retryTests = @('TestWebListenRetryRealUDPCollision', 'TestWebListenRetryPolicy', 'TestWebListenRetryCloseFailure')
$testOutput = & go test ./internal/tunnel -run ('^(' + ($retryTests -join '|') + ')$') -count=3 -timeout=90s -json
$testExit = $LASTEXITCODE
$testOutput | Write-Output
if ($testExit -ne 0) { exit $testExit }
$testEvents = @($testOutput | ForEach-Object { $_ | ConvertFrom-Json -ErrorAction Stop })
foreach ($testName in $retryTests) {
$passes = @($testEvents | Where-Object { $_.Test -eq $testName -and $_.Action -eq 'pass' }).Count
$skips = @($testEvents | Where-Object { $_.Test -eq $testName -and $_.Action -eq 'skip' }).Count
if ($passes -ne 3 -or $skips -ne 0) {
throw "$testName requires exactly 3 passes and 0 skips; got $passes passes and $skips skips"
}
Write-Output "Verified ${testName}: $passes passes, $skips skips"
}
- name: Exercise static cover path policy on Windows
shell: pwsh
run: |
Expand Down
6 changes: 6 additions & 0 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ Web-cover presents one HTTPS origin over TCP and UDP on the same numeric port:
| HTTP/2 | TCP/TLS 1.2 or 1.3 | configured cover | TLS 1.3-only standard `CONNECT` stream for TCP |
| HTTP/3 | QUIC/UDP/TLS 1.3 | configured cover | standard `CONNECT` for TCP; RFC 9298 `CONNECT-UDP` with HTTP Datagrams for UDP |

When both effective listen ports are `0`, startup binds TCP first and uses its
selected port for UDP. If that port is already occupied by UDP, startup closes
TCP before trying a new pair, with at most eight total attempts. A fixed port
on either side disables this retry. Other bind errors and rollback failures
are returned immediately; the server never silently switches a fixed port.

HTTP/1.1 and HTTP/2 cover responses include an `Alt-Svc` value for the bound H3
port. Only H2 and H3 requests with method `CONNECT`, an empty path and query,
and exactly one valid `Proxy-Authorization` value are eligible to become a TCP
Expand Down
94 changes: 94 additions & 0 deletions internal/tunnel/web_close_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package tunnel

import (
"errors"
"fmt"
"net"
"net/http"
"testing"
)

type webCloseErrorWithCustomIs struct{ inner error }

func (e *webCloseErrorWithCustomIs) Error() string { return "custom cleanup failure" }
func (e *webCloseErrorWithCustomIs) Unwrap() error { return e.inner }
func (e *webCloseErrorWithCustomIs) Is(target error) bool {
return target == net.ErrClosed
}

type webCloseUnknownMultiError struct{ parts []error }

func (e *webCloseUnknownMultiError) Error() string { return "unknown cleanup aggregate" }
func (e *webCloseUnknownMultiError) Unwrap() []error { return e.parts }

// A valid error need not be comparable; normalization must not compare two
// arbitrary error interface values to detect that a child changed.
type webCloseSliceError []string

func (e webCloseSliceError) Error() string { return "non-comparable cleanup failure" }

func TestNormalizeWebServerCloseErrorTrees(t *testing.T) {
failure := errors.New("meaningful listener cleanup failure")
wrappedFailure := fmt.Errorf("listener close: %w", failure)
mixed := errors.Join(net.ErrClosed, failure, http.ErrServerClosed)
wrappedMixed := fmt.Errorf("listener close: %w", mixed)
customIs := &webCloseErrorWithCustomIs{inner: failure}
customIsLeaf := &webCloseErrorWithCustomIs{}
emptyAggregate := &webCloseUnknownMultiError{}
nilAggregate := &webCloseUnknownMultiError{parts: []error{nil}}
unknownAggregate := &webCloseUnknownMultiError{parts: []error{wrappedFailure}}
unmodifiedJoin := errors.Join(wrappedFailure, errors.New("another failure"))
for _, test := range []struct {
name string
input error
wantNil bool
wantIdentity bool
wantFailure bool
wantStripped bool
}{
{name: "nil", wantNil: true},
{name: "closed", input: net.ErrClosed, wantNil: true},
{name: "server_closed", input: http.ErrServerClosed, wantNil: true},
{name: "wrapped_closed", input: fmt.Errorf("listen: %w", net.ErrClosed), wantNil: true},
{name: "all_benign", input: errors.Join(net.ErrClosed, fmt.Errorf("server: %w", http.ErrServerClosed)), wantNil: true},
{name: "wrapped_all_benign", input: fmt.Errorf("close: %w", errors.Join(net.ErrClosed, http.ErrServerClosed)), wantNil: true},
{name: "nested_all_benign", input: errors.Join(fmt.Errorf("listener: %w", errors.Join(net.ErrClosed, http.ErrServerClosed)), net.ErrClosed), wantNil: true},
{name: "failure", input: failure, wantIdentity: true, wantFailure: true},
{name: "wrapped_failure", input: wrappedFailure, wantIdentity: true, wantFailure: true},
{name: "unmodified_join", input: unmodifiedJoin, wantIdentity: true, wantFailure: true},
{name: "mixed", input: mixed, wantFailure: true, wantStripped: true},
{name: "nested_mixed", input: errors.Join(net.ErrClosed, mixed), wantFailure: true, wantStripped: true},
{name: "wrapped_mixed", input: wrappedMixed, wantIdentity: true, wantFailure: true},
{name: "nested_wrapped_mixed", input: errors.Join(net.ErrClosed, wrappedMixed), wantFailure: true},
{name: "custom_is", input: customIs, wantIdentity: true, wantFailure: true},
{name: "custom_is_leaf", input: customIsLeaf, wantIdentity: true},
{name: "empty_unknown_aggregate", input: emptyAggregate, wantIdentity: true},
{name: "nil_unknown_aggregate", input: nilAggregate, wantIdentity: true},
{name: "unknown_aggregate", input: unknownAggregate, wantIdentity: true, wantFailure: true},
{name: "uncomparable_leaf", input: webCloseSliceError{"failure"}},
{name: "uncomparable_in_join", input: errors.Join(net.ErrClosed, webCloseSliceError{"failure"}), wantStripped: true},
} {
t.Run(test.name, func(t *testing.T) {
got := normalizeWebServerCloseError(test.input)
if test.wantNil {
if got != nil {
t.Fatalf("normalization = %v, want nil", got)
}
return
}
if got == nil {
t.Fatal("normalization discarded a meaningful or unknown error")
}
// Only known-comparable values request this identity assertion.
if test.wantIdentity && got != test.input {
t.Fatal("normalization replaced an unmodified error or diagnostic wrapper")
}
if test.wantFailure && !errors.Is(got, failure) {
t.Fatalf("normalization lost meaningful cleanup failure: %v", got)
}
if test.wantStripped && (errors.Is(got, net.ErrClosed) || errors.Is(got, http.ErrServerClosed)) {
t.Fatalf("direct joined error retained benign leaves: %v", got)
}
})
}
}
10 changes: 2 additions & 8 deletions internal/tunnel/web_h2.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,8 @@ func (s *WebH2Server) Serve(ctx context.Context) error {
func (s *WebH2Server) Close() error {
s.closeOnce.Do(func() {
ownerErr := s.tcpOwner.close()
serverErr := s.server.Close()
listenerErr := s.listener.Close()
if errors.Is(serverErr, http.ErrServerClosed) || errors.Is(serverErr, net.ErrClosed) {
serverErr = nil
}
if errors.Is(listenerErr, net.ErrClosed) {
listenerErr = nil
}
serverErr := normalizeWebServerCloseError(s.server.Close())
listenerErr := normalizeWebServerCloseError(s.listener.Close())
s.closeErr = errors.Join(ownerErr, serverErr, listenerErr)
})
return s.closeErr
Expand Down
Loading
Loading