fix(gateway): enforce audited production traversal
This commit is contained in:
+61
-13
@@ -198,12 +198,16 @@ func pinnedApolloTLSConfig(work protocol.ProviderSessionWork) (*tls.Config, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *NativeApolloBackend) Setup(ctx context.Context, request LaunchRequest) ([]byte, error) {
|
||||
func (b *NativeApolloBackend) Setup(ctx context.Context, request LaunchRequest, management []byte) ([]byte, error) {
|
||||
work := request.ProviderWork
|
||||
if err := work.Validate(); err != nil || validateApolloStreamPolicy(work.StreamPolicy) != nil ||
|
||||
request.SessionID == "" || request.SessionID != work.SessionID || work.ProviderProfile != ProviderProfileApollo {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
info, err := ParseManagementXML(management)
|
||||
if err != nil || validateApolloProviderStreamPolicy(info, work.StreamPolicy) != nil {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
client, err := newPinnedApolloHTTPClient(work)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -281,10 +285,11 @@ type nativeApolloSession struct {
|
||||
audioPing []byte
|
||||
videoPing []byte
|
||||
sessionID string
|
||||
video chan []byte
|
||||
audio chan []byte
|
||||
video chan ProviderMedia
|
||||
audio chan ProviderMedia
|
||||
events chan ProviderEvent
|
||||
mu sync.Mutex
|
||||
mediaMu sync.Mutex
|
||||
controlMu sync.Mutex
|
||||
state protocol.ProviderState
|
||||
pressed map[string]InputEvent
|
||||
@@ -299,10 +304,15 @@ type nativeApolloSession struct {
|
||||
allowApplicationTermination bool
|
||||
terminationErr error
|
||||
mediaDrops atomic.Uint64
|
||||
mediaQuiesced atomic.Bool
|
||||
mediaIngress atomic.Uint64
|
||||
mediaRecovered atomic.Uint64
|
||||
mediaEnqueued atomic.Uint64
|
||||
mediaQueueMaximum atomic.Uint64
|
||||
}
|
||||
|
||||
func newNativeApolloSession(sessionID string) *nativeApolloSession {
|
||||
return &nativeApolloSession{sessionID: sessionID, video: make(chan []byte, 16), audio: make(chan []byte, 16), events: make(chan ProviderEvent, 16), state: protocol.ProviderState{Version: "1", SessionID: sessionID, State: ProviderStateStarting, Channels: []string{"video", "audio", "input", "feedback"}}, pressed: make(map[string]InputEvent), done: make(chan struct{}), readDone: make(chan struct{})}
|
||||
return &nativeApolloSession{sessionID: sessionID, video: make(chan ProviderMedia, 16), audio: make(chan ProviderMedia, 16), events: make(chan ProviderEvent, 16), state: protocol.ProviderState{Version: "1", SessionID: sessionID, State: ProviderStateStarting, Channels: []string{"video", "audio", "input", "feedback"}}, pressed: make(map[string]InputEvent), done: make(chan struct{}), readDone: make(chan struct{})}
|
||||
}
|
||||
|
||||
func newNativeApolloProviderSession(ctx context.Context, setup *apolloRTSPSetup) (*nativeApolloSession, error) {
|
||||
@@ -405,8 +415,8 @@ func (s *nativeApolloSession) Ready(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *nativeApolloSession) Video() <-chan []byte { return s.video }
|
||||
func (s *nativeApolloSession) Audio() <-chan []byte { return s.audio }
|
||||
func (s *nativeApolloSession) Video() <-chan ProviderMedia { return s.video }
|
||||
func (s *nativeApolloSession) Audio() <-chan ProviderMedia { return s.audio }
|
||||
func (s *nativeApolloSession) Events() <-chan ProviderEvent { return s.events }
|
||||
|
||||
func (s *nativeApolloSession) Input(ctx context.Context, event InputEvent) error {
|
||||
@@ -646,6 +656,7 @@ func (s *nativeApolloSession) handleApolloControlPayload(_ uint8, _ bool, payloa
|
||||
s.handleApolloDisconnect(ErrProviderMalformed)
|
||||
return
|
||||
}
|
||||
s.quiesceMedia()
|
||||
s.mu.Lock()
|
||||
s.state.State = ProviderStateTerminated
|
||||
s.mu.Unlock()
|
||||
@@ -686,6 +697,7 @@ func (s *nativeApolloSession) handleApolloDisconnect(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
s.quiesceMedia()
|
||||
s.disconnectOnce.Do(func() {
|
||||
s.mu.Lock()
|
||||
if s.state.State == ProviderStateTerminated {
|
||||
@@ -701,13 +713,45 @@ func (s *nativeApolloSession) handleApolloDisconnect(err error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *nativeApolloSession) quiesceMedia() {
|
||||
if !s.mediaQuiesced.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
if s.audioConn != nil {
|
||||
_ = s.audioConn.Close()
|
||||
}
|
||||
if s.videoConn != nil {
|
||||
_ = s.videoConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nativeApolloSession) closeMediaChannels() {
|
||||
s.channelsOnce.Do(func() {
|
||||
s.mediaMu.Lock()
|
||||
defer s.mediaMu.Unlock()
|
||||
close(s.video)
|
||||
close(s.audio)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *nativeApolloSession) enqueueMedia(output chan ProviderMedia, payload []byte, receivedAt time.Time) bool {
|
||||
s.mediaMu.Lock()
|
||||
defer s.mediaMu.Unlock()
|
||||
if len(payload) == 0 || s.mediaQuiesced.Load() {
|
||||
return false
|
||||
}
|
||||
s.mediaRecovered.Add(1)
|
||||
media := ProviderMedia{Payload: payload, ReceivedAt: receivedAt, EnqueuedAt: time.Now()}
|
||||
if pushLatest(output, media) {
|
||||
s.mediaDrops.Add(1)
|
||||
}
|
||||
s.mediaEnqueued.Add(1)
|
||||
depth := uint64(len(output))
|
||||
for maximum := s.mediaQueueMaximum.Load(); depth > maximum && !s.mediaQueueMaximum.CompareAndSwap(maximum, depth); maximum = s.mediaQueueMaximum.Load() {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *nativeApolloSession) readUDPMedia() {
|
||||
if s.media == nil {
|
||||
close(s.readDone)
|
||||
@@ -716,14 +760,18 @@ func (s *nativeApolloSession) readUDPMedia() {
|
||||
}
|
||||
var readers sync.WaitGroup
|
||||
readers.Add(2)
|
||||
read := func(conn *net.UDPConn, output chan []byte, video bool) {
|
||||
read := func(conn *net.UDPConn, output chan ProviderMedia, video bool) {
|
||||
defer readers.Done()
|
||||
buffer := make([]byte, apolloMediaMaximumPacket+1)
|
||||
for {
|
||||
if s.mediaQuiesced.Load() {
|
||||
return
|
||||
}
|
||||
if err := conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil {
|
||||
return
|
||||
}
|
||||
count, err := conn.Read(buffer)
|
||||
receivedAt := time.Now()
|
||||
if err != nil {
|
||||
if networkErr, ok := err.(net.Error); ok && networkErr.Timeout() {
|
||||
select {
|
||||
@@ -738,6 +786,10 @@ func (s *nativeApolloSession) readUDPMedia() {
|
||||
if count > apolloMediaMaximumPacket {
|
||||
continue
|
||||
}
|
||||
if s.mediaQuiesced.Load() {
|
||||
return
|
||||
}
|
||||
s.mediaIngress.Add(1)
|
||||
var payloads [][]byte
|
||||
if video {
|
||||
shard, openErr := s.media.OpenVideo(buffer[:count])
|
||||
@@ -764,11 +816,7 @@ func (s *nativeApolloSession) readUDPMedia() {
|
||||
continue
|
||||
}
|
||||
for _, payload := range payloads {
|
||||
if len(payload) != 0 {
|
||||
if pushLatest(output, payload) {
|
||||
s.mediaDrops.Add(1)
|
||||
}
|
||||
}
|
||||
s.enqueueMedia(output, payload, receivedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -781,7 +829,7 @@ func (s *nativeApolloSession) readUDPMedia() {
|
||||
}()
|
||||
}
|
||||
|
||||
func pushLatest(channel chan []byte, payload []byte) bool {
|
||||
func pushLatest[T any](channel chan T, payload T) bool {
|
||||
select {
|
||||
case channel <- payload:
|
||||
return false
|
||||
|
||||
@@ -82,14 +82,18 @@ func TestNativeApolloSetupRejectsUnsupportedStreamPolicyBeforeProviderReadiness(
|
||||
ClipboardPolicy: protocol.ClipboardPolicy{MaxTextBytes: 65536, MaxUpdatesPerMinute: 30},
|
||||
}
|
||||
for name, policy := range map[string]protocol.ProviderStreamPolicy{
|
||||
"audio-disabled": {ResolutionWidth: 1920, ResolutionHeight: 1080, Fps: 60, Codec: "H264", BitrateKbps: 8000, AudioEnabled: false},
|
||||
"av1": {ResolutionWidth: 3840, ResolutionHeight: 2160, Fps: 60, Codec: "AV1", BitrateKbps: 50000, AudioEnabled: true},
|
||||
"audio-disabled": {ResolutionWidth: 1920, ResolutionHeight: 1080, Fps: 60, Codec: "H264", BitrateKbps: 8000, AudioEnabled: false},
|
||||
"av1": {ResolutionWidth: 3840, ResolutionHeight: 2160, Fps: 60, Codec: "AV1", BitrateKbps: 50000, AudioEnabled: true},
|
||||
"h264-resolution": {ResolutionWidth: 4097, ResolutionHeight: 2160, Fps: 60, Codec: "H264", BitrateKbps: 50000, AudioEnabled: true},
|
||||
"hevc-resolution": {ResolutionWidth: 8193, ResolutionHeight: 4320, Fps: 60, Codec: "HEVC", BitrateKbps: 80000, AudioEnabled: true},
|
||||
"fps": {ResolutionWidth: 1920, ResolutionHeight: 1080, Fps: 241, Codec: "H264", BitrateKbps: 8000, AudioEnabled: true},
|
||||
"bitrate-cap": {ResolutionWidth: 1920, ResolutionHeight: 1080, Fps: 60, Codec: "H264", BitrateKbps: 125001, AudioEnabled: true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
work.StreamPolicy = policy
|
||||
_, err := NewNativeApolloBackend().Setup(context.Background(), LaunchRequest{
|
||||
SessionID: "session-1", ProviderProfile: ProviderProfileApollo, ProviderWork: work,
|
||||
})
|
||||
}, nil)
|
||||
if !errors.Is(err, ErrProviderMalformed) {
|
||||
t.Fatalf("Setup() error = %v, want ErrProviderMalformed before provider readiness", err)
|
||||
}
|
||||
@@ -97,6 +101,73 @@ func TestNativeApolloSetupRejectsUnsupportedStreamPolicyBeforeProviderReadiness(
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeApolloRejectsProviderCapabilityMismatchBeforeInventoryOrLaunch(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
var paths []string
|
||||
management := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
paths = append(paths, request.URL.Path)
|
||||
switch request.URL.Path {
|
||||
case "/serverinfo":
|
||||
_, _ = response.Write([]byte("<root><uniqueid>apollo-server</uniqueid><ServerCodecModeSupport>1</ServerCodecModeSupport><MaxLumaPixelsHEVC>0</MaxLumaPixelsHEVC></root>"))
|
||||
case "/applist":
|
||||
_, _ = response.Write([]byte("<root><App><ID>42</ID></App></root>"))
|
||||
default:
|
||||
http.Error(response, "unexpected provider request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
management.TLS = serverTLS
|
||||
management.StartTLS()
|
||||
defer management.Close()
|
||||
host, portText, err := net.SplitHostPort(management.Listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := strconv.ParseInt(portText, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pinned := sha256.Sum256(serverTLS.Certificates[0].Certificate[0])
|
||||
work := protocol.ProviderSessionWork{
|
||||
Version: "1", SessionID: "session-source-policy", GatewayID: "gateway-1",
|
||||
ExpiresAt: "2099-01-01T00:00:00Z", ProviderProfile: ProviderProfileApollo,
|
||||
ProviderIdentity: "apollo-server#sha256:" + hex.EncodeToString(pinned[:]), PolicyVersionID: "policy-1",
|
||||
ApplicationID: "42", ClientID: "paired-client",
|
||||
StreamPolicy: protocol.ProviderStreamPolicy{
|
||||
ResolutionWidth: 2560, ResolutionHeight: 1440, Fps: 120,
|
||||
Codec: "HEVC", BitrateKbps: 40000, AudioEnabled: true,
|
||||
},
|
||||
ManagementHost: host, ManagementPort: port, StreamHost: host, StreamPort: 47984,
|
||||
ClientCertificatePem: certificatePEM(t, clientTLS.Certificates[0]),
|
||||
ClientPrivateKeyPem: privateKeyPEM(t, clientTLS.Certificates[0]),
|
||||
ServerCertificatePem: certificatePEM(t, tls.Certificate{Certificate: [][]byte{serverTLS.Certificates[0].Certificate[1]}}),
|
||||
ClipboardPolicy: protocol.ClipboardPolicy{MaxTextBytes: 65536, MaxUpdatesPerMinute: 30},
|
||||
}
|
||||
adapter := NewApolloAdapter(NewNativeApolloBackend(), ProviderIdentity{UniqueID: "apollo-server", Fingerprint: "sha256:" + hex.EncodeToString(pinned[:])})
|
||||
if _, err := adapter.Start(context.Background(), LaunchRequest{
|
||||
SessionID: work.SessionID, ProviderProfile: ProviderProfileApollo,
|
||||
ProviderIdentity: work.ProviderIdentity, ProviderWork: work,
|
||||
}); err == nil {
|
||||
t.Fatal("Start() accepted a provider/source policy mismatch")
|
||||
}
|
||||
if got := strings.Join(paths, ","); got != "/serverinfo" {
|
||||
t.Fatalf("provider requests before capability rejection = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApolloProviderCapabilityRejectsHEVCLumaDowngrade(t *testing.T) {
|
||||
info := ManagementInfo{
|
||||
ServerCodecModeSupport: 0x101, HasServerCodecModeSupport: true,
|
||||
MaxLumaPixelsHEVC: 1920 * 1080, HasMaxLumaPixelsHEVC: true,
|
||||
}
|
||||
policy := protocol.ProviderStreamPolicy{
|
||||
ResolutionWidth: 3840, ResolutionHeight: 2160, Fps: 60,
|
||||
Codec: "HEVC", BitrateKbps: 80000, AudioEnabled: true,
|
||||
}
|
||||
if err := validateApolloProviderStreamPolicy(info, policy); !errors.Is(err, ErrProviderMalformed) {
|
||||
t.Fatalf("provider HEVC luma downgrade error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeApolloSetupRequiresModernEncryptedRTSPOrder(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
streamListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
@@ -213,7 +284,7 @@ func TestNativeApolloSetupRequiresModernEncryptedRTSPOrder(t *testing.T) {
|
||||
}
|
||||
switch request.URL.Path {
|
||||
case "/serverinfo":
|
||||
_, _ = response.Write([]byte("<root><uniqueid>apollo-server</uniqueid></root>"))
|
||||
_, _ = response.Write([]byte("<root><uniqueid>apollo-server</uniqueid><ServerCodecModeSupport>257</ServerCodecModeSupport><MaxLumaPixelsHEVC>1869449984</MaxLumaPixelsHEVC></root>"))
|
||||
case "/applist":
|
||||
if request.URL.Query().Get("uniqueid") != "paired-client" {
|
||||
http.Error(response, "wrong client", http.StatusBadRequest)
|
||||
@@ -291,7 +362,7 @@ func TestNativeApolloSetupRequiresModernEncryptedRTSPOrder(t *testing.T) {
|
||||
ProviderApplicationTerminationAllowed: true,
|
||||
}
|
||||
backend := NewNativeApolloBackend()
|
||||
response, err := backend.Setup(context.Background(), LaunchRequest{SessionID: "session-1", ProviderProfile: ProviderProfileApollo, ProviderWork: work})
|
||||
response, err := backend.Setup(context.Background(), LaunchRequest{SessionID: "session-1", ProviderProfile: ProviderProfileApollo, ProviderWork: work}, []byte("<root><uniqueid>apollo-server</uniqueid><ServerCodecModeSupport>257</ServerCodecModeSupport><MaxLumaPixelsHEVC>1869449984</MaxLumaPixelsHEVC></root>"))
|
||||
if err != nil {
|
||||
t.Fatalf("Setup() error = %v", err)
|
||||
}
|
||||
@@ -474,7 +545,8 @@ func TestNativeApolloSetupRequiresModernEncryptedRTSPOrder(t *testing.T) {
|
||||
t.Fatal("encrypted host termination was not forwarded")
|
||||
}
|
||||
select {
|
||||
case payload := <-session.Video():
|
||||
case media := <-session.Video():
|
||||
payload := media.Payload
|
||||
if len(payload) != 1001 || payload[0] != 'A' || payload[1000] != 'B' {
|
||||
t.Fatalf("source-shaped video relay = %x", payload)
|
||||
}
|
||||
@@ -482,7 +554,8 @@ func TestNativeApolloSetupRequiresModernEncryptedRTSPOrder(t *testing.T) {
|
||||
t.Fatal("source-shaped video was not relayed")
|
||||
}
|
||||
select {
|
||||
case payload := <-session.Audio():
|
||||
case media := <-session.Audio():
|
||||
payload := media.Payload
|
||||
if string(payload) != "A" {
|
||||
t.Fatalf("source-shaped audio relay = %x", payload)
|
||||
}
|
||||
@@ -608,7 +681,8 @@ func TestNativeApolloSessionRelaysOnlyAuthenticatedEncodedUDPMedia(t *testing.T)
|
||||
}
|
||||
|
||||
select {
|
||||
case payload := <-session.Video():
|
||||
case media := <-session.Video():
|
||||
payload := media.Payload
|
||||
if string(payload) != string([]byte{0x01, 0x02, 0x03}) {
|
||||
t.Fatalf("video relay = %x, want encoded payload", payload)
|
||||
}
|
||||
@@ -617,7 +691,8 @@ func TestNativeApolloSessionRelaysOnlyAuthenticatedEncodedUDPMedia(t *testing.T)
|
||||
}
|
||||
for _, want := range wantAudio {
|
||||
select {
|
||||
case payload := <-session.Audio():
|
||||
case media := <-session.Audio():
|
||||
payload := media.Payload
|
||||
if string(payload) != string(want) {
|
||||
t.Fatalf("audio relay = %x, want %x", payload, want)
|
||||
}
|
||||
@@ -632,7 +707,8 @@ func TestNativeApolloSessionRelaysOnlyAuthenticatedEncodedUDPMedia(t *testing.T)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case payload := <-session.Video():
|
||||
case media := <-session.Video():
|
||||
payload := media.Payload
|
||||
if len(payload) != 1001 || payload[0] != 'A' || payload[999] != 'A' || payload[1000] != 'B' {
|
||||
t.Fatalf("FEC video relay = %x", payload)
|
||||
}
|
||||
@@ -646,7 +722,8 @@ func TestNativeApolloSessionRelaysOnlyAuthenticatedEncodedUDPMedia(t *testing.T)
|
||||
}
|
||||
for _, want := range [][]byte{{'A'}, {'B'}, {'C'}, {'D'}} {
|
||||
select {
|
||||
case payload := <-session.Audio():
|
||||
case media := <-session.Audio():
|
||||
payload := media.Payload
|
||||
if string(payload) != string(want) {
|
||||
t.Fatalf("FEC audio relay = %x, want %x", payload, want)
|
||||
}
|
||||
@@ -670,7 +747,8 @@ func TestNativeApolloSessionRelaysOnlyAuthenticatedEncodedUDPMedia(t *testing.T)
|
||||
}
|
||||
for _, want := range [][]byte{{0xa0}, {0xa1}, {0xa2}, {0xa3}} {
|
||||
select {
|
||||
case payload := <-session.Audio():
|
||||
case media := <-session.Audio():
|
||||
payload := media.Payload
|
||||
if string(payload) != string(want) {
|
||||
t.Fatalf("post-loss audio relay = %x, want %x", payload, want)
|
||||
}
|
||||
|
||||
@@ -515,7 +515,32 @@ func apolloAnnounceProfile(policy protocol.ProviderStreamPolicy) ([]byte, error)
|
||||
}
|
||||
|
||||
func validateApolloStreamPolicy(policy protocol.ProviderStreamPolicy) error {
|
||||
if err := policy.Validate(); err != nil || !policy.AudioEnabled || (policy.Codec != "H264" && policy.Codec != "HEVC") {
|
||||
if err := policy.Validate(); err != nil || !policy.AudioEnabled || policy.BitrateKbps > 125000 ||
|
||||
(policy.Codec != "H264" && policy.Codec != "HEVC") {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
if (policy.Codec == "H264" && (policy.ResolutionWidth > 4096 || policy.ResolutionHeight > 4096)) ||
|
||||
(policy.Codec == "HEVC" && (policy.ResolutionWidth > 8192 || policy.ResolutionHeight > 8192)) {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateApolloProviderStreamPolicy(info ManagementInfo, policy protocol.ProviderStreamPolicy) error {
|
||||
if validateApolloStreamPolicy(policy) != nil || !info.HasServerCodecModeSupport || !info.HasMaxLumaPixelsHEVC {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
switch policy.Codec {
|
||||
case "H264":
|
||||
if info.ServerCodecModeSupport&0x1 == 0 {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
case "HEVC":
|
||||
luma := uint64(policy.ResolutionWidth) * uint64(policy.ResolutionHeight)
|
||||
if info.ServerCodecModeSupport&0x100 == 0 || info.MaxLumaPixelsHEVC == 0 || luma > info.MaxLumaPixelsHEVC {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
default:
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
return nil
|
||||
|
||||
+8
-20
@@ -8,8 +8,6 @@ import (
|
||||
|
||||
var ErrNoCapabilityOverlap = errors.New("no capability overlap")
|
||||
|
||||
const defaultClientDecode = "h264-hevc-opus"
|
||||
|
||||
func DefaultCapabilities() protocol.CapabilityProfile {
|
||||
return protocol.CapabilityProfile{
|
||||
Transport: "quic-tls13",
|
||||
@@ -17,29 +15,19 @@ func DefaultCapabilities() protocol.CapabilityProfile {
|
||||
Media: "encoded",
|
||||
Audio: "encoded",
|
||||
SourceRateControl: "server",
|
||||
ClientDecode: defaultClientDecode,
|
||||
ClientDecode: []string{"hevc-opus", "h264-opus"},
|
||||
}
|
||||
}
|
||||
|
||||
func capabilityProfileUnset(profile protocol.CapabilityProfile) bool {
|
||||
return profile.Transport == "" && profile.Framing == "" && profile.Media == "" &&
|
||||
profile.Audio == "" && profile.SourceRateControl == "" && len(profile.ClientDecode) == 0
|
||||
}
|
||||
|
||||
func IntersectCapabilities(profiles ...protocol.CapabilityProfile) (protocol.CapabilityProfile, error) {
|
||||
if len(profiles) == 0 {
|
||||
selected, err := protocol.IntersectCapabilityProfiles(profiles...)
|
||||
if err != nil {
|
||||
return protocol.CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if err := profile.Validate(); err != nil {
|
||||
return protocol.CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||
}
|
||||
}
|
||||
selected := profiles[0]
|
||||
for _, profile := range profiles[1:] {
|
||||
if selected.Transport != profile.Transport ||
|
||||
selected.Framing != profile.Framing ||
|
||||
selected.Media != profile.Media ||
|
||||
selected.Audio != profile.Audio ||
|
||||
selected.SourceRateControl != profile.SourceRateControl ||
|
||||
selected.ClientDecode != profile.ClientDecode {
|
||||
return protocol.CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||
}
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
+19
-8
@@ -10,14 +10,15 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
gatewayFeedbackHeaderSize = 8
|
||||
gatewayFeedbackClient = 0
|
||||
gatewayFeedbackGateway = 1
|
||||
gatewayFeedbackIDR = 1
|
||||
gatewayFeedbackFEC = 2
|
||||
gatewayFeedbackTerminated = 0x10
|
||||
gatewayFeedbackRumble = 0x11
|
||||
gatewayFeedbackHDR = 0x12
|
||||
gatewayFeedbackHeaderSize = 8
|
||||
gatewayFeedbackClient = 0
|
||||
gatewayFeedbackGateway = 1
|
||||
gatewayFeedbackIDR = 1
|
||||
gatewayFeedbackFEC = 2
|
||||
gatewayFeedbackTerminated = 0x10
|
||||
gatewayFeedbackRumble = 0x11
|
||||
gatewayFeedbackHDR = 0x12
|
||||
gatewayFeedbackDisconnected = 0x13
|
||||
)
|
||||
|
||||
type gatewayFeedbackMessage struct {
|
||||
@@ -43,6 +44,11 @@ func EncodeProviderEvent(event ProviderEvent) ([]byte, error) {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
return encodeGatewayFeedback(gatewayFeedbackGateway, gatewayFeedbackHDR, event.Payload)
|
||||
case ProviderEventDisconnected:
|
||||
if len(event.Payload) != 0 {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
return encodeGatewayFeedback(gatewayFeedbackGateway, gatewayFeedbackDisconnected, nil)
|
||||
default:
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
@@ -120,6 +126,11 @@ func DecodeProviderEvent(data []byte) (ProviderEvent, error) {
|
||||
return ProviderEvent{}, ErrProviderMalformed
|
||||
}
|
||||
return ProviderEvent{Kind: ProviderEventHDR, Payload: message.payload}, nil
|
||||
case gatewayFeedbackDisconnected:
|
||||
if len(message.payload) != 0 {
|
||||
return ProviderEvent{}, ErrProviderMalformed
|
||||
}
|
||||
return ProviderEvent{Kind: ProviderEventDisconnected}, nil
|
||||
default:
|
||||
return ProviderEvent{}, ErrProviderMalformed
|
||||
}
|
||||
|
||||
+267
-22
@@ -14,6 +14,7 @@ import (
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -97,6 +98,13 @@ func TestClientFeedbackUsesFixedProtocolVGFVector(t *testing.T) {
|
||||
if _, err := DecodeClientFeedback([]byte{'F', 'B', 'R', 'K', 0}); !errors.Is(err, ErrProviderMalformed) {
|
||||
t.Fatalf("legacy feedback accepted: %v", err)
|
||||
}
|
||||
disconnected, err := EncodeProviderEvent(ProviderEvent{Kind: ProviderEventDisconnected})
|
||||
if err != nil || hex.EncodeToString(disconnected) != "5647463101130000" {
|
||||
t.Fatalf("disconnected event vector = %x, %v", disconnected, err)
|
||||
}
|
||||
if event, err := DecodeProviderEvent(disconnected); err != nil || event.Kind != ProviderEventDisconnected {
|
||||
t.Fatalf("decoded disconnected event = %#v, %v", event, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityIntersectionAndBoundedQueue(t *testing.T) {
|
||||
@@ -124,6 +132,24 @@ func TestCapabilityIntersectionAndBoundedQueue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerRejectsPartiallyConfiguredCapabilities(t *testing.T) {
|
||||
serverTLS, _ := testTLS(t)
|
||||
fake := NewFakeApollo(FakeApolloConfig{Now: time.Now()})
|
||||
server, err := NewServer(ServerConfig{
|
||||
TLSConfig: serverTLS, GatewayID: "gateway-1",
|
||||
Capabilities: protocol.CapabilityProfile{SourceRateControl: "server"},
|
||||
ProviderCapabilities: DefaultCapabilities(),
|
||||
Admission: &oneTimeAdmission{},
|
||||
Provider: fake,
|
||||
})
|
||||
if server != nil {
|
||||
_ = server.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("partial capability configuration was silently replaced with defaults")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyntheticImpairmentPacingAndResourceBounds(t *testing.T) {
|
||||
payload := make([]byte, 1179*16+1)
|
||||
if _, err := FragmentPayload(ChannelVideo, 1, 0, payload); !errors.Is(err, ErrFrameFragmentedLimit) {
|
||||
@@ -187,10 +213,10 @@ func TestApolloFixturesAndLifecycle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := <-session.Video(); string(got) != string(video) {
|
||||
if got := <-session.Video(); string(got.Payload) != string(video) {
|
||||
t.Fatalf("video changed: %x", got)
|
||||
}
|
||||
if got := <-session.Audio(); string(got) != string(audio) {
|
||||
if got := <-session.Audio(); string(got.Payload) != string(audio) {
|
||||
t.Fatalf("audio changed: %x", got)
|
||||
}
|
||||
if err := session.Input(context.Background(), InputEvent{Sequence: 1, Device: "keyboard", Code: 7, Pressed: true}); err != nil {
|
||||
@@ -321,11 +347,96 @@ func TestAdmissionQUICMTLSRelayAndCleanup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayTelemetrySeparatesQueueProcessingAndPacing(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
session := &fakeSession{
|
||||
video: make(chan ProviderMedia, 1),
|
||||
audio: make(chan ProviderMedia),
|
||||
events: make(chan ProviderEvent, 1),
|
||||
clipboardWrites: make(chan string, 1),
|
||||
state: protocol.ProviderState{
|
||||
Version: "1", SessionID: "session-timing", State: ProviderStateStarting,
|
||||
Channels: []string{"video", "audio", "input", "feedback"},
|
||||
},
|
||||
pressed: make(map[string]struct{}),
|
||||
}
|
||||
provider := providerStartFunc(func(context.Context, LaunchRequest) (ProviderSession, error) {
|
||||
enqueuedAt := time.Now()
|
||||
session.video <- ProviderMedia{Payload: bytesRepeat(0x5a, 2000), ReceivedAt: enqueuedAt, EnqueuedAt: enqueuedAt}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
session.mu.Lock()
|
||||
session.state.State = ProviderStateReady
|
||||
session.mu.Unlock()
|
||||
return session, nil
|
||||
})
|
||||
authority := protocol.SessionAuthority{
|
||||
Version: "1", SessionID: "session-timing", GatewayID: "gateway-1", Audience: "versevdi-gateway",
|
||||
ExpiresAt: time.Now().Add(5 * time.Second).UTC().Format(time.RFC3339Nano),
|
||||
Capabilities: DefaultCapabilities(), ProviderProfile: ProviderProfileApollo,
|
||||
ProviderIdentity: "apollo-fixture-1#sha256:fixture-apollo-1",
|
||||
}
|
||||
admission := &oneTimeAdmission{authority: authority, released: make(chan struct{}), disableClipboard: true}
|
||||
server, err := NewServer(ServerConfig{
|
||||
ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: authority.GatewayID,
|
||||
Capabilities: DefaultCapabilities(), ProviderCapabilities: DefaultCapabilities(),
|
||||
Admission: admission, Provider: provider, PacerKbps: 24,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
serveDone := make(chan error, 1)
|
||||
go func() { serveDone <- server.Serve(ctx) }()
|
||||
request := protocol.TunnelAdmissionRequest{
|
||||
Version: "1", SessionID: authority.SessionID, GatewayID: authority.GatewayID, Audience: authority.Audience,
|
||||
Grant: strings.Repeat("g", 64), ClientNonce: "nonce-0000000001", DeviceSignature: strings.Repeat("s", 86),
|
||||
Capabilities: DefaultCapabilities(),
|
||||
}
|
||||
client, err := Dial(context.Background(), server.Addr().String(), clientTLS, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receiveCtx, receiveCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
for index := byte(0); index < 2; index++ {
|
||||
frame, err := client.ReceiveFrame(receiveCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frame.FragmentIndex != index || frame.FragmentCount != 2 {
|
||||
t.Fatalf("timing frame = %#v", frame)
|
||||
}
|
||||
}
|
||||
receiveCancel()
|
||||
metrics := server.Metrics()
|
||||
if metrics.ProcessingSamples != 1 {
|
||||
t.Fatalf("timing samples = %d, want one provider unit", metrics.ProcessingSamples)
|
||||
}
|
||||
if metrics.MediaPackets != 2 {
|
||||
t.Fatalf("media packets = %d, want two fragments", metrics.MediaPackets)
|
||||
}
|
||||
queue, processing, pacing := time.Duration(metrics.QueueDelayNanos), time.Duration(metrics.ProcessingDelayNanos), time.Duration(metrics.PacingDelayNanos)
|
||||
if queue < 40*time.Millisecond || queue > 150*time.Millisecond {
|
||||
t.Fatalf("queue residence = %s, want the controlled 60ms provider queue wait", queue)
|
||||
}
|
||||
if processing >= 100*time.Millisecond {
|
||||
t.Fatalf("processing = %s, pacing leaked into gateway processing", processing)
|
||||
}
|
||||
if pacing < 500*time.Millisecond {
|
||||
t.Fatalf("pacing = %s, want the controlled scheduler wait", pacing)
|
||||
}
|
||||
_ = client.Close()
|
||||
cancel()
|
||||
_ = server.Close()
|
||||
if err := <-serveDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRejectsProviderWorkOutsideNegotiatedDecodeProfile(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
fake := NewFakeApollo(FakeApolloConfig{Now: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)})
|
||||
capabilities := DefaultCapabilities()
|
||||
capabilities.ClientDecode = "h264-opus"
|
||||
capabilities.ClientDecode = []string{"h264-opus"}
|
||||
authority := protocol.SessionAuthority{
|
||||
Version: "1", SessionID: "session-policy", GatewayID: "gateway-1", Audience: "versevdi-gateway",
|
||||
ExpiresAt: time.Now().Add(5 * time.Second).UTC().Format(time.RFC3339Nano),
|
||||
@@ -371,6 +482,75 @@ func TestGatewayRejectsProviderWorkOutsideNegotiatedDecodeProfile(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayNegotiatesRegisteredProfilesWithIndependentClient(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
clientProfiles []string
|
||||
selected string
|
||||
codec string
|
||||
}{
|
||||
{name: "h264-only", clientProfiles: []string{"h264-opus"}, selected: "h264-opus", codec: "H264"},
|
||||
{name: "hevc-only", clientProfiles: []string{"hevc-opus"}, selected: "hevc-opus", codec: "HEVC"},
|
||||
{name: "policy-selects-hevc", clientProfiles: []string{"h264-opus", "hevc-opus"}, selected: "hevc-opus", codec: "HEVC"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
fake := NewFakeApollo(FakeApolloConfig{Now: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)})
|
||||
clientCapabilities := DefaultCapabilities()
|
||||
clientCapabilities.ClientDecode = test.clientProfiles
|
||||
authority := protocol.SessionAuthority{
|
||||
Version: "1", SessionID: "session-profile-" + test.name, GatewayID: "gateway-1", Audience: "versevdi-gateway",
|
||||
ExpiresAt: time.Now().Add(5 * time.Second).UTC().Format(time.RFC3339Nano),
|
||||
Capabilities: clientCapabilities, ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key(),
|
||||
}
|
||||
admission := &oneTimeAdmission{
|
||||
authority: authority, released: make(chan struct{}), disableClipboard: true,
|
||||
streamPolicy: protocol.ProviderStreamPolicy{
|
||||
ResolutionWidth: 1920, ResolutionHeight: 1080, Fps: 60,
|
||||
Codec: test.codec, BitrateKbps: 8000, AudioEnabled: true,
|
||||
},
|
||||
}
|
||||
started := make(chan LaunchRequest, 1)
|
||||
provider := providerStartFunc(func(ctx context.Context, request LaunchRequest) (ProviderSession, error) {
|
||||
started <- request
|
||||
return fake.Start(ctx, request)
|
||||
})
|
||||
server, err := NewServer(ServerConfig{
|
||||
ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: authority.GatewayID,
|
||||
Capabilities: DefaultCapabilities(), ProviderCapabilities: DefaultCapabilities(),
|
||||
Admission: admission, Provider: provider,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
serveDone := make(chan error, 1)
|
||||
go func() { serveDone <- server.Serve(ctx) }()
|
||||
request := protocol.TunnelAdmissionRequest{
|
||||
Version: "1", SessionID: authority.SessionID, GatewayID: authority.GatewayID, Audience: authority.Audience,
|
||||
Grant: strings.Repeat("g", 64), ClientNonce: "nonce-0000000001", DeviceSignature: strings.Repeat("s", 86),
|
||||
Capabilities: clientCapabilities,
|
||||
}
|
||||
client, err := Dial(context.Background(), server.Addr().String(), clientTLS, request)
|
||||
if err != nil {
|
||||
cancel()
|
||||
_ = server.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
launch := <-started
|
||||
if !reflect.DeepEqual(launch.Capabilities.ClientDecode, []string{test.selected}) {
|
||||
t.Fatalf("provider selected profiles = %v", launch.Capabilities.ClientDecode)
|
||||
}
|
||||
_ = client.Close()
|
||||
cancel()
|
||||
_ = server.Close()
|
||||
if err := <-serveDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredChannelFramesTraversePublicTransport(t *testing.T) {
|
||||
h := newGatewayTransportHarness(t)
|
||||
|
||||
@@ -525,9 +705,60 @@ func TestProviderTerminationEndsPublicGatewaySession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEncryptedNativeHostTerminationEndsPublicGatewaySession(t *testing.T) {
|
||||
h := newNativeGatewayLifecycleHarness(t, "session-native-terminal")
|
||||
h.native.handleApolloControlPayload(apolloChannelGeneric, true, sourceSealHostControl(t, h.key, 0, apolloControlTypeTerm, []byte{1, 2, 3, 4}))
|
||||
tryQueueNativeMedia(h.native, h.native.video, []byte("queued-video"))
|
||||
tryQueueNativeMedia(h.native, h.native.audio, []byte("queued-audio"))
|
||||
|
||||
eventCtx, eventCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
event, err := h.client.ReceiveProviderEvent(eventCtx)
|
||||
eventCancel()
|
||||
if err != nil || event.Kind != ProviderEventTerminated {
|
||||
t.Fatalf("native provider termination = %#v, %v", event, err)
|
||||
}
|
||||
tryQueueNativeMedia(h.native, h.native.video, []byte("new-video"))
|
||||
tryQueueNativeMedia(h.native, h.native.audio, []byte("new-audio"))
|
||||
h.assertNoMedia(t)
|
||||
h.waitReleased(t)
|
||||
if states := h.reporter.States(); len(states) == 0 || states[len(states)-1].State != ProviderStateTerminated {
|
||||
t.Fatalf("provider states = %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeENetDisconnectQuiescesPublicGatewaySession(t *testing.T) {
|
||||
h := newNativeGatewayLifecycleHarness(t, "session-native-disconnect")
|
||||
h.native.handleApolloDisconnect(ErrProviderDisconnected)
|
||||
tryQueueNativeMedia(h.native, h.native.video, []byte("queued-video"))
|
||||
tryQueueNativeMedia(h.native, h.native.audio, []byte("queued-audio"))
|
||||
|
||||
eventCtx, eventCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
event, err := h.client.ReceiveProviderEvent(eventCtx)
|
||||
eventCancel()
|
||||
if err != nil || event.Kind != ProviderEventDisconnected {
|
||||
t.Fatalf("native provider disconnect = %#v, %v", event, err)
|
||||
}
|
||||
tryQueueNativeMedia(h.native, h.native.video, []byte("new-video"))
|
||||
tryQueueNativeMedia(h.native, h.native.audio, []byte("new-audio"))
|
||||
h.assertNoMedia(t)
|
||||
h.waitReleased(t)
|
||||
if states := h.reporter.States(); len(states) == 0 || states[len(states)-1].State != ProviderStateDisconnected || states[len(states)-1].CleanupPending {
|
||||
t.Fatalf("provider states = %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
type nativeGatewayLifecycleHarness struct {
|
||||
native *nativeApolloSession
|
||||
key []byte
|
||||
client *Client
|
||||
admission *oneTimeAdmission
|
||||
reporter *recordingProviderStateReporter
|
||||
}
|
||||
|
||||
func newNativeGatewayLifecycleHarness(t *testing.T, sessionID string) nativeGatewayLifecycleHarness {
|
||||
t.Helper()
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
key := []byte("0123456789abcdef")
|
||||
native := newNativeApolloSession("session-native-terminal")
|
||||
native := newNativeApolloSession(sessionID)
|
||||
control, err := newApolloControlCodec(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -557,7 +788,6 @@ func TestEncryptedNativeHostTerminationEndsPublicGatewaySession(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
serveDone := make(chan error, 1)
|
||||
go func() { serveDone <- server.Serve(ctx) }()
|
||||
request := protocol.TunnelAdmissionRequest{
|
||||
@@ -569,28 +799,39 @@ func TestEncryptedNativeHostTerminationEndsPublicGatewaySession(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
native.handleApolloControlPayload(apolloChannelGeneric, true, sourceSealHostControl(t, key, 0, apolloControlTypeTerm, []byte{1, 2, 3, 4}))
|
||||
eventCtx, eventCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
event, err := client.ReceiveProviderEvent(eventCtx)
|
||||
eventCancel()
|
||||
if err != nil || event.Kind != ProviderEventTerminated {
|
||||
t.Fatalf("native provider termination = %#v, %v", event, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
cancel()
|
||||
_ = server.Close()
|
||||
if err := <-serveDone; err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
})
|
||||
return nativeGatewayLifecycleHarness{native: native, key: key, client: client, admission: admission, reporter: reporter}
|
||||
}
|
||||
|
||||
func (h nativeGatewayLifecycleHarness) waitReleased(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-admission.released:
|
||||
case <-h.admission.released:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("native termination did not release admission")
|
||||
t.Fatal("native terminal state did not release admission")
|
||||
}
|
||||
if states := reporter.States(); len(states) == 0 || states[len(states)-1].State != ProviderStateTerminated {
|
||||
t.Fatalf("provider states = %#v", states)
|
||||
}
|
||||
_ = client.Close()
|
||||
_ = server.Close()
|
||||
if err := <-serveDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
func (h nativeGatewayLifecycleHarness) assertNoMedia(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
if frame, err := h.client.ReceiveFrame(ctx); err == nil {
|
||||
t.Fatalf("media crossed after native terminal signal: channel=%d payload=%q", frame.Channel, frame.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func tryQueueNativeMedia(session *nativeApolloSession, channel chan ProviderMedia, payload []byte) {
|
||||
session.enqueueMedia(channel, payload, time.Now())
|
||||
}
|
||||
|
||||
func TestProviderDisconnectEndsPublicGatewaySessionReconnectable(t *testing.T) {
|
||||
h := newGatewayTransportHarnessWithoutClipboard(t)
|
||||
h.drainInitialMedia(t)
|
||||
@@ -752,6 +993,7 @@ type oneTimeAdmission struct {
|
||||
releases atomic.Int64
|
||||
released chan struct{}
|
||||
streamPolicy protocol.ProviderStreamPolicy
|
||||
providerWork *protocol.ProviderSessionWork
|
||||
disableClipboard bool
|
||||
}
|
||||
|
||||
@@ -795,9 +1037,12 @@ func (a *oneTimeAdmission) Admit(context.Context, protocol.TunnelAdmissionReques
|
||||
}
|
||||
|
||||
func (a *oneTimeAdmission) ProviderWork(_ context.Context, authority protocol.SessionAuthority) (protocol.ProviderSessionWork, error) {
|
||||
if authority != a.authority {
|
||||
if !reflect.DeepEqual(authority, a.authority) {
|
||||
return protocol.ProviderSessionWork{}, ErrAdmissionRejected
|
||||
}
|
||||
if a.providerWork != nil {
|
||||
return *a.providerWork, nil
|
||||
}
|
||||
streamPolicy := a.streamPolicy
|
||||
if streamPolicy == (protocol.ProviderStreamPolicy{}) {
|
||||
streamPolicy = protocol.ProviderStreamPolicy{ResolutionWidth: 1920, ResolutionHeight: 1080, Fps: 60, Codec: "H264", BitrateKbps: 8000, AudioEnabled: true}
|
||||
|
||||
+52
-18
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -66,8 +67,12 @@ func (i ProviderIdentity) Validate(now time.Time, expected ProviderIdentity) err
|
||||
}
|
||||
|
||||
type ManagementInfo struct {
|
||||
Identity ProviderIdentity
|
||||
Name string
|
||||
Identity ProviderIdentity
|
||||
Name string
|
||||
ServerCodecModeSupport uint32
|
||||
MaxLumaPixelsHEVC uint64
|
||||
HasServerCodecModeSupport bool
|
||||
HasMaxLumaPixelsHEVC bool
|
||||
}
|
||||
|
||||
func ParseManagementXML(data []byte) (ManagementInfo, error) {
|
||||
@@ -82,6 +87,8 @@ func ParseManagementXML(data []byte) (ManagementInfo, error) {
|
||||
NotBefore string `xml:"not_before"`
|
||||
NotAfter string `xml:"not_after"`
|
||||
Name string `xml:"name"`
|
||||
CodecModes string `xml:"ServerCodecModeSupport"`
|
||||
MaxHEVCLuma string `xml:"MaxLumaPixelsHEVC"`
|
||||
}
|
||||
decoder := xml.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.Strict = true
|
||||
@@ -108,7 +115,24 @@ func ParseManagementXML(data []byte) (ManagementInfo, error) {
|
||||
if identity.UniqueID == "" || len(identity.UniqueID) > 128 || len(identity.Fingerprint) > 256 {
|
||||
return ManagementInfo{}, ErrProviderMalformed
|
||||
}
|
||||
return ManagementInfo{Identity: identity, Name: document.Name}, nil
|
||||
info := ManagementInfo{Identity: identity, Name: document.Name}
|
||||
if document.CodecModes != "" {
|
||||
value, parseErr := strconv.ParseUint(document.CodecModes, 10, 32)
|
||||
if parseErr != nil {
|
||||
return ManagementInfo{}, ErrProviderMalformed
|
||||
}
|
||||
info.ServerCodecModeSupport = uint32(value)
|
||||
info.HasServerCodecModeSupport = true
|
||||
}
|
||||
if document.MaxHEVCLuma != "" {
|
||||
value, parseErr := strconv.ParseUint(document.MaxHEVCLuma, 10, 64)
|
||||
if parseErr != nil {
|
||||
return ManagementInfo{}, ErrProviderMalformed
|
||||
}
|
||||
info.MaxLumaPixelsHEVC = value
|
||||
info.HasMaxLumaPixelsHEVC = true
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
type RTSPResponse struct {
|
||||
@@ -206,14 +230,20 @@ type ProviderTelemetry struct {
|
||||
MediaDrops uint64
|
||||
}
|
||||
|
||||
type ProviderMedia struct {
|
||||
Payload []byte
|
||||
ReceivedAt time.Time
|
||||
EnqueuedAt time.Time
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Start(context.Context, LaunchRequest) (ProviderSession, error)
|
||||
}
|
||||
|
||||
type ProviderSession interface {
|
||||
Ready(context.Context) error
|
||||
Video() <-chan []byte
|
||||
Audio() <-chan []byte
|
||||
Video() <-chan ProviderMedia
|
||||
Audio() <-chan ProviderMedia
|
||||
Events() <-chan ProviderEvent
|
||||
Input(context.Context, InputEvent) error
|
||||
Feedback(context.Context, Feedback) error
|
||||
@@ -227,7 +257,7 @@ type ProviderSession interface {
|
||||
|
||||
type ApolloBackend interface {
|
||||
Management(context.Context, LaunchRequest) ([]byte, error)
|
||||
Setup(context.Context, LaunchRequest) ([]byte, error)
|
||||
Setup(context.Context, LaunchRequest, []byte) ([]byte, error)
|
||||
Open(context.Context, LaunchRequest, RTSPResponse) (ProviderSession, error)
|
||||
}
|
||||
|
||||
@@ -268,7 +298,7 @@ func (a *ApolloAdapter) Start(ctx context.Context, request LaunchRequest) (Provi
|
||||
if request.ProviderIdentity != "" && info.Identity.UniqueID != expected.UniqueID {
|
||||
return nil, ErrProviderIdentity
|
||||
}
|
||||
rawRTSP, err := a.backend.Setup(ctx, request)
|
||||
rawRTSP, err := a.backend.Setup(ctx, request, management)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -352,7 +382,7 @@ func (f *FakeApollo) Management(context.Context, LaunchRequest) ([]byte, error)
|
||||
return []byte(fmt.Sprintf("<root><unique_id>%s</unique_id><fingerprint>%s</fingerprint><not_before>%s</not_before><not_after>%s</not_after><name>fixture-apollo</name></root>", identity.UniqueID, identity.Fingerprint, f.config.Now.Add(-time.Hour).Format(time.RFC3339), f.config.Now.Add(time.Hour).Format(time.RFC3339))), nil
|
||||
}
|
||||
|
||||
func (f *FakeApollo) Setup(context.Context, LaunchRequest) ([]byte, error) {
|
||||
func (f *FakeApollo) Setup(context.Context, LaunchRequest, []byte) ([]byte, error) {
|
||||
if f.config.Failure == FakeFailureMalformed {
|
||||
return []byte("RTSP/1.0 200 OK\r\n\r\n"), nil
|
||||
}
|
||||
@@ -362,8 +392,8 @@ func (f *FakeApollo) Setup(context.Context, LaunchRequest) ([]byte, error) {
|
||||
func (f *FakeApollo) Open(_ context.Context, request LaunchRequest, _ RTSPResponse) (ProviderSession, error) {
|
||||
session := &fakeSession{
|
||||
failure: f.config.Failure,
|
||||
video: make(chan []byte, 16),
|
||||
audio: make(chan []byte, 16),
|
||||
video: make(chan ProviderMedia, 16),
|
||||
audio: make(chan ProviderMedia, 16),
|
||||
events: make(chan ProviderEvent, 16),
|
||||
clipboardWrites: make(chan string, 1),
|
||||
state: protocol.ProviderState{Version: "1", SessionID: request.SessionID, State: ProviderStateStarting, Channels: []string{"video", "audio", "input", "feedback"}},
|
||||
@@ -405,8 +435,8 @@ func (f *FakeApollo) DisconnectProvider() {
|
||||
type fakeSession struct {
|
||||
mu sync.Mutex
|
||||
failure FakeFailure
|
||||
video chan []byte
|
||||
audio chan []byte
|
||||
video chan ProviderMedia
|
||||
audio chan ProviderMedia
|
||||
events chan ProviderEvent
|
||||
state protocol.ProviderState
|
||||
pressed map[string]struct{}
|
||||
@@ -432,8 +462,8 @@ func (s *fakeSession) Ready(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSession) Video() <-chan []byte { return s.video }
|
||||
func (s *fakeSession) Audio() <-chan []byte { return s.audio }
|
||||
func (s *fakeSession) Video() <-chan ProviderMedia { return s.video }
|
||||
func (s *fakeSession) Audio() <-chan ProviderMedia { return s.audio }
|
||||
func (s *fakeSession) Events() <-chan ProviderEvent { return s.events }
|
||||
|
||||
func (s *fakeSession) EmitEvent(event ProviderEvent) {
|
||||
@@ -449,15 +479,17 @@ func (s *fakeSession) EmitVideo(payload []byte) {
|
||||
if s.state.State == ProviderStateTerminating || s.state.State == ProviderStateTerminated || s.state.State == ProviderStateDisconnected {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
media := ProviderMedia{Payload: append([]byte(nil), payload...), ReceivedAt: now, EnqueuedAt: now}
|
||||
select {
|
||||
case s.video <- append([]byte(nil), payload...):
|
||||
case s.video <- media:
|
||||
default:
|
||||
select {
|
||||
case <-s.video:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case s.video <- append([]byte(nil), payload...):
|
||||
case s.video <- media:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -469,15 +501,17 @@ func (s *fakeSession) EmitAudio(payload []byte) {
|
||||
if s.state.State == ProviderStateTerminating || s.state.State == ProviderStateTerminated || s.state.State == ProviderStateDisconnected {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
media := ProviderMedia{Payload: append([]byte(nil), payload...), ReceivedAt: now, EnqueuedAt: now}
|
||||
select {
|
||||
case s.audio <- append([]byte(nil), payload...):
|
||||
case s.audio <- media:
|
||||
default:
|
||||
select {
|
||||
case <-s.audio:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case s.audio <- append([]byte(nil), payload...):
|
||||
case s.audio <- media:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestQualificationShortProcessingWritesRawArtifact(t *testing.T) {
|
||||
func TestQualificationProcessingPreservesPayload(t *testing.T) {
|
||||
profile := qualificationMediaProfiles()[0]
|
||||
payload := qualificationPayload(profile)
|
||||
trace, elapsed, err := newQualificationPath(t, profile.BitrateKbps).traverse(t, payload)
|
||||
trace, elapsed, err := newQualificationPath(t, profile, profile.BitrateKbps).traverse(t, payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -190,12 +190,12 @@ func TestQualificationSixImpairmentProfilesTraverseProductionPath(t *testing.T)
|
||||
|
||||
func TestQualificationUsesPublicQUICAndProductionPacer(t *testing.T) {
|
||||
qualificationTraverseProfiles(t, qualificationMediaProfiles())
|
||||
evidence, err := qualificationPacerEvidence(filepath.Join(t.TempDir(), "fairness.csv.gz"))
|
||||
evidence, err := qualificationPacerEvidence(t, filepath.Join(t.TempDir(), "fairness.csv.gz"), 2*time.Second, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(evidence.PerFlowBytes) != 8 || len(evidence.CapacitySteps) != 2 ||
|
||||
len(evidence.Series) != 80 || evidence.RawSamplesSHA256 == "" || evidence.JainIndex < 0.99 {
|
||||
len(evidence.Series) != 6 || evidence.RawSamplesSHA256 == "" || evidence.JainIndex < 0.99 {
|
||||
t.Fatalf("pacer evidence = %#v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+103
-37
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -19,14 +20,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHelloLimit = 16 * 1024
|
||||
defaultControlLimit = 128 * 1024
|
||||
clientControlBacklog = 64
|
||||
applicationError = quic.ApplicationErrorCode(0x100)
|
||||
terminalFeedbackDrain = 100 * time.Millisecond
|
||||
controlFlowID = "control.ack.v1"
|
||||
inputFlowID = "input.sequenced.v1"
|
||||
clipboardFlowID = "clipboard.text.v1"
|
||||
defaultHelloLimit = 16 * 1024
|
||||
defaultControlLimit = 128 * 1024
|
||||
clientControlBacklog = 64
|
||||
applicationError = quic.ApplicationErrorCode(0x100)
|
||||
controlFlowID = "control.ack.v1"
|
||||
inputFlowID = "input.sequenced.v1"
|
||||
clipboardFlowID = "clipboard.text.v1"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -101,12 +101,15 @@ func NewServer(config ServerConfig) (*Server, error) {
|
||||
if err := validateServerTLS(config.TLSConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.Capabilities == (protocol.CapabilityProfile{}) {
|
||||
if capabilityProfileUnset(config.Capabilities) {
|
||||
config.Capabilities = DefaultCapabilities()
|
||||
}
|
||||
if config.ProviderCapabilities == (protocol.CapabilityProfile{}) {
|
||||
if capabilityProfileUnset(config.ProviderCapabilities) {
|
||||
config.ProviderCapabilities = DefaultCapabilities()
|
||||
}
|
||||
if config.Capabilities.Validate() != nil || config.ProviderCapabilities.Validate() != nil {
|
||||
return nil, ErrNoCapabilityOverlap
|
||||
}
|
||||
if config.ProviderProfile == "" {
|
||||
config.ProviderProfile = ProviderProfileApollo
|
||||
}
|
||||
@@ -241,6 +244,13 @@ func (s *Server) handleConnection(parent context.Context, connection *quic.Conn)
|
||||
_ = writeStableError(stream, "no_capability_overlap", err, false)
|
||||
return
|
||||
}
|
||||
selected, err = selectApolloPolicyCapabilities(work.StreamPolicy, selected)
|
||||
if err != nil {
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
s.metrics.AdmissionRejects.Add(1)
|
||||
_ = writeStableError(stream, "no_capability_overlap", err, false)
|
||||
return
|
||||
}
|
||||
clipboard, err := newClipboardGate(work.ClipboardPolicy, time.Now)
|
||||
if err != nil {
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
@@ -335,13 +345,26 @@ func apolloPolicyMatchesCapabilities(policy protocol.ProviderStreamPolicy, capab
|
||||
if validateApolloStreamPolicy(policy) != nil || capabilities.Audio != "encoded" {
|
||||
return false
|
||||
}
|
||||
required := apolloPolicyProfile(policy)
|
||||
return required != "" && slices.Contains(capabilities.ClientDecode, required)
|
||||
}
|
||||
|
||||
func selectApolloPolicyCapabilities(policy protocol.ProviderStreamPolicy, capabilities protocol.CapabilityProfile) (protocol.CapabilityProfile, error) {
|
||||
if !apolloPolicyMatchesCapabilities(policy, capabilities) {
|
||||
return protocol.CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||
}
|
||||
capabilities.ClientDecode = []string{apolloPolicyProfile(policy)}
|
||||
return capabilities, nil
|
||||
}
|
||||
|
||||
func apolloPolicyProfile(policy protocol.ProviderStreamPolicy) string {
|
||||
switch policy.Codec {
|
||||
case "H264":
|
||||
return capabilities.ClientDecode == "h264-opus" || capabilities.ClientDecode == defaultClientDecode
|
||||
return "h264-opus"
|
||||
case "HEVC":
|
||||
return capabilities.ClientDecode == "hevc-opus" || capabilities.ClientDecode == defaultClientDecode
|
||||
return "hevc-opus"
|
||||
default:
|
||||
return false
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,9 +393,12 @@ type gatewaySession struct {
|
||||
cleanupOnce sync.Once
|
||||
inputMu sync.Mutex
|
||||
controlWriteMu sync.Mutex
|
||||
outputMu sync.Mutex
|
||||
pressed map[string]struct{}
|
||||
sequence atomic.Uint32
|
||||
mediaDrops uint64
|
||||
mediaQuiesced bool
|
||||
terminalSent atomic.Bool
|
||||
endReason error
|
||||
result chan error
|
||||
}
|
||||
@@ -408,6 +434,13 @@ func (s *gatewaySession) run() {
|
||||
case s.endReason = <-s.result:
|
||||
}
|
||||
s.cancel()
|
||||
if s.terminalSent.Load() {
|
||||
s.cleanup()
|
||||
select {
|
||||
case <-s.connection.Context().Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *gatewaySession) providerEventLoop() {
|
||||
@@ -420,28 +453,32 @@ func (s *gatewaySession) providerEventLoop() {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Kind == ProviderEventDisconnected {
|
||||
s.result <- ErrProviderDisconnected
|
||||
return
|
||||
terminal := event.Kind == ProviderEventTerminated || event.Kind == ProviderEventDisconnected
|
||||
if terminal {
|
||||
s.outputMu.Lock()
|
||||
s.mediaQuiesced = true
|
||||
}
|
||||
payload, err := EncodeProviderEvent(event)
|
||||
if err == nil {
|
||||
err = s.sendControl(s.sequence.Add(1), payload)
|
||||
}
|
||||
if terminal {
|
||||
s.outputMu.Unlock()
|
||||
}
|
||||
if err != nil {
|
||||
s.result <- err
|
||||
return
|
||||
}
|
||||
if event.Kind == ProviderEventTerminated {
|
||||
timer := time.NewTimer(terminalFeedbackDrain)
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if terminal {
|
||||
s.terminalSent.Store(true)
|
||||
}
|
||||
switch event.Kind {
|
||||
case ProviderEventTerminated:
|
||||
s.result <- ErrProviderTerminated
|
||||
return
|
||||
case ProviderEventDisconnected:
|
||||
s.result <- ErrProviderDisconnected
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -604,21 +641,21 @@ func (s *gatewaySession) mediaLoop() {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case payload, ok := <-video:
|
||||
case media, ok := <-video:
|
||||
if !ok {
|
||||
video = nil
|
||||
continue
|
||||
}
|
||||
if err := s.sendMedia(ChannelVideo, payload); err != nil {
|
||||
if err := s.forwardMedia(ChannelVideo, media); err != nil {
|
||||
s.result <- err
|
||||
return
|
||||
}
|
||||
case payload, ok := <-audio:
|
||||
case media, ok := <-audio:
|
||||
if !ok {
|
||||
audio = nil
|
||||
continue
|
||||
}
|
||||
if err := s.sendMedia(ChannelAudio, payload); err != nil {
|
||||
if err := s.forwardMedia(ChannelAudio, media); err != nil {
|
||||
s.result <- err
|
||||
return
|
||||
}
|
||||
@@ -627,12 +664,31 @@ func (s *gatewaySession) mediaLoop() {
|
||||
s.result <- ErrProviderDisconnected
|
||||
}
|
||||
|
||||
func (s *gatewaySession) sendMedia(channel byte, payload []byte) error {
|
||||
func (s *gatewaySession) forwardMedia(channel byte, media ProviderMedia) error {
|
||||
s.outputMu.Lock()
|
||||
defer s.outputMu.Unlock()
|
||||
state := s.provider.State().State
|
||||
if s.mediaQuiesced || state == ProviderStateTerminated || state == ProviderStateDisconnected {
|
||||
s.mediaQuiesced = true
|
||||
return nil
|
||||
}
|
||||
return s.sendMedia(channel, media)
|
||||
}
|
||||
|
||||
func (s *gatewaySession) sendMedia(channel byte, media ProviderMedia) error {
|
||||
dequeuedAt := time.Now()
|
||||
if media.EnqueuedAt.IsZero() || media.EnqueuedAt.After(dequeuedAt) {
|
||||
media.EnqueuedAt = dequeuedAt
|
||||
}
|
||||
if media.ReceivedAt.IsZero() || media.ReceivedAt.After(media.EnqueuedAt) {
|
||||
media.ReceivedAt = media.EnqueuedAt
|
||||
}
|
||||
processingStarted := time.Now()
|
||||
frames, err := FragmentPayload(channel, s.sequence.Add(1), uint64(time.Now().UnixMilli()), payload)
|
||||
frames, err := FragmentPayload(channel, s.sequence.Add(1), uint64(time.Now().UnixMilli()), media.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pacingDelay time.Duration
|
||||
for _, frame := range frames {
|
||||
encoded, err := EncodeFrame(frame)
|
||||
if err != nil {
|
||||
@@ -642,16 +698,18 @@ func (s *gatewaySession) sendMedia(channel byte, payload []byte) error {
|
||||
if err := s.server.pacer.wait(s.ctx, s.authority.SessionID, len(encoded)); err != nil {
|
||||
return err
|
||||
}
|
||||
s.server.metrics.PacingDelayNanos.Add(uint64(time.Since(pacingStarted)))
|
||||
s.server.metrics.QueueDelayNanos.Add(uint64(time.Since(pacingStarted)))
|
||||
pacingDelay += time.Since(pacingStarted)
|
||||
if err := s.connection.SendDatagram(encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
s.server.metrics.MediaPackets.Add(1)
|
||||
s.server.metrics.MediaBytes.Add(uint64(len(encoded)))
|
||||
s.server.metrics.ProcessingDelayNanos.Add(uint64(time.Since(processingStarted)))
|
||||
s.server.metrics.ProcessingSamples.Add(1)
|
||||
}
|
||||
processingDelay := media.EnqueuedAt.Sub(media.ReceivedAt) + time.Since(processingStarted) - pacingDelay
|
||||
s.server.metrics.QueueDelayNanos.Add(uint64(dequeuedAt.Sub(media.EnqueuedAt)))
|
||||
s.server.metrics.ProcessingDelayNanos.Add(uint64(max(processingDelay, 0)))
|
||||
s.server.metrics.PacingDelayNanos.Add(uint64(pacingDelay))
|
||||
s.server.metrics.ProcessingSamples.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -799,7 +857,9 @@ func (s *gatewaySession) cleanup() {
|
||||
} else if err := s.server.config.Admission.Release(cleanupCtx, s.authority); err != nil {
|
||||
s.server.metrics.ProviderErrors.Add(1)
|
||||
}
|
||||
_ = s.connection.CloseWithError(applicationError, "session closed")
|
||||
if !s.terminalSent.Load() {
|
||||
_ = s.connection.CloseWithError(applicationError, "session closed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if errors.Is(s.endReason, ErrProviderDisconnected) {
|
||||
@@ -812,7 +872,9 @@ func (s *gatewaySession) cleanup() {
|
||||
if err := s.server.reportProviderState(cleanupCtx, state); err != nil {
|
||||
s.server.metrics.ProviderErrors.Add(1)
|
||||
}
|
||||
_ = s.connection.CloseWithError(applicationError, "session closed")
|
||||
if !s.terminalSent.Load() {
|
||||
_ = s.connection.CloseWithError(applicationError, "session closed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1001,7 +1063,11 @@ func (c *Client) ReceiveProviderEvent(ctx context.Context) (ProviderEvent, error
|
||||
if len(payload) > 1024 {
|
||||
return ProviderEvent{}, ErrProviderMalformed
|
||||
}
|
||||
return DecodeProviderEvent(payload)
|
||||
event, err := DecodeProviderEvent(payload)
|
||||
if err == nil && (event.Kind == ProviderEventTerminated || event.Kind == ProviderEventDisconnected) {
|
||||
_ = c.Close()
|
||||
}
|
||||
return event, err
|
||||
}
|
||||
|
||||
func (c *Client) ReceiveClipboard(ctx context.Context) (protocol.GatewayClipboardText, error) {
|
||||
|
||||
Reference in New Issue
Block a user