@@ -59,10 +59,145 @@ func (fakeAgentProvider) Close() error {
5959return nil
6060}
6161
62+ type channelCloser struct {
63+ closeFn func ()
64+ }
65+
66+ func (c * channelCloser )Close ()error {
67+ c .closeFn ()
68+ return nil
69+ }
70+
6271func TestWatchAgentContainers (t * testing.T ) {
6372t .Parallel ()
6473
65- t .Run ("WebSocketClosesProperly" ,func (t * testing.T ) {
74+ t .Run ("CoderdWebSocketCanHandleClientClosing" ,func (t * testing.T ) {
75+ t .Parallel ()
76+
77+ // This test ensures that the agent containers `/watch` websocket can gracefully
78+ // handle the client websocket closing. This test was created in
79+ // response to this issue: https://github.com/coder/coder/issues/19449
80+
81+ var (
82+ ctx = testutil .Context (t ,testutil .WaitLong )
83+ logger = slogtest .Make (t ,& slogtest.Options {IgnoreErrors :true }).Leveled (slog .LevelDebug ).Named ("coderd" )
84+
85+ mCtrl = gomock .NewController (t )
86+ mDB = dbmock .NewMockStore (mCtrl )
87+ mCoordinator = tailnettest .NewMockCoordinator (mCtrl )
88+ mAgentConn = agentconnmock .NewMockAgentConn (mCtrl )
89+
90+ fAgentProvider = fakeAgentProvider {
91+ agentConn :func (ctx context.Context ,agentID uuid.UUID ) (_ workspacesdk.AgentConn ,release func (),_ error ) {
92+ return mAgentConn ,func () {},nil
93+ },
94+ }
95+
96+ workspaceID = uuid .New ()
97+ agentID = uuid .New ()
98+ resourceID = uuid .New ()
99+ jobID = uuid .New ()
100+ buildID = uuid .New ()
101+
102+ containersCh = make (chan codersdk.WorkspaceAgentListContainersResponse )
103+
104+ r = chi .NewMux ()
105+
106+ api = API {
107+ ctx :ctx ,
108+ Options :& Options {
109+ AgentInactiveDisconnectTimeout :testutil .WaitShort ,
110+ Database :mDB ,
111+ Logger :logger ,
112+ DeploymentValues :& codersdk.DeploymentValues {},
113+ TailnetCoordinator :tailnettest .NewFakeCoordinator (),
114+ },
115+ }
116+ )
117+
118+ var tailnetCoordinator tailnet.Coordinator = mCoordinator
119+ api .TailnetCoordinator .Store (& tailnetCoordinator )
120+ api .agentProvider = fAgentProvider
121+
122+ // Setup: Allow `ExtractWorkspaceAgentParams` to complete.
123+ mDB .EXPECT ().GetWorkspaceAgentByID (gomock .Any (),agentID ).Return (database.WorkspaceAgent {
124+ ID :agentID ,
125+ ResourceID :resourceID ,
126+ LifecycleState :database .WorkspaceAgentLifecycleStateReady ,
127+ FirstConnectedAt : sql.NullTime {Valid :true ,Time :dbtime .Now ()},
128+ LastConnectedAt : sql.NullTime {Valid :true ,Time :dbtime .Now ()},
129+ },nil )
130+ mDB .EXPECT ().GetWorkspaceResourceByID (gomock .Any (),resourceID ).Return (database.WorkspaceResource {
131+ ID :resourceID ,
132+ JobID :jobID ,
133+ },nil )
134+ mDB .EXPECT ().GetProvisionerJobByID (gomock .Any (),jobID ).Return (database.ProvisionerJob {
135+ ID :jobID ,
136+ Type :database .ProvisionerJobTypeWorkspaceBuild ,
137+ },nil )
138+ mDB .EXPECT ().GetWorkspaceBuildByJobID (gomock .Any (),jobID ).Return (database.WorkspaceBuild {
139+ WorkspaceID :workspaceID ,
140+ ID :buildID ,
141+ },nil )
142+
143+ // And: Allow `db2dsk.WorkspaceAgent` to complete.
144+ mCoordinator .EXPECT ().Node (gomock .Any ()).Return (nil )
145+
146+ // And: Allow `WatchContainers` to be called, returing our `containersCh` channel.
147+ mAgentConn .EXPECT ().WatchContainers (gomock .Any (),gomock .Any ()).
148+ DoAndReturn (func (_ context.Context ,_ slog.Logger ) (<- chan codersdk.WorkspaceAgentListContainersResponse , io.Closer ,error ) {
149+ return containersCh ,& channelCloser {closeFn :func () {
150+ close (containersCh )
151+ }},nil
152+ })
153+
154+ // And: We mount the HTTP Handler
155+ r .With (httpmw .ExtractWorkspaceAgentParam (mDB )).
156+ Get ("/workspaceagents/{workspaceagent}/containers/watch" ,api .watchWorkspaceAgentContainers )
157+
158+ // Given: We create the HTTP server
159+ srv := httptest .NewServer (r )
160+ defer srv .Close ()
161+
162+ // And: Dial the WebSocket
163+ wsURL := strings .Replace (srv .URL ,"http://" ,"ws://" ,1 )
164+ conn ,resp ,err := websocket .Dial (ctx ,fmt .Sprintf ("%s/workspaceagents/%s/containers/watch" ,wsURL ,agentID ),nil )
165+ require .NoError (t ,err )
166+ if resp .Body != nil {
167+ defer resp .Body .Close ()
168+ }
169+
170+ // And: Create a streaming decoder
171+ decoder := wsjson .NewDecoder [codersdk.WorkspaceAgentListContainersResponse ](conn ,websocket .MessageText ,logger )
172+ defer decoder .Close ()
173+ decodeCh := decoder .Chan ()
174+
175+ // And: We can successfully send through the channel.
176+ testutil .RequireSend (ctx ,t ,containersCh , codersdk.WorkspaceAgentListContainersResponse {
177+ Containers : []codersdk.WorkspaceAgentContainer {{
178+ ID :"test-container-id" ,
179+ }},
180+ })
181+
182+ // And: Receive the data.
183+ containerResp := testutil .RequireReceive (ctx ,t ,decodeCh )
184+ require .Len (t ,containerResp .Containers ,1 )
185+ require .Equal (t ,"test-container-id" ,containerResp .Containers [0 ].ID )
186+
187+ // When: We close the WebSocket
188+ conn .Close (websocket .StatusNormalClosure ,"test closing connection" )
189+
190+ // Then: We expect `containersCh` to be closed.
191+ select {
192+ case <- ctx .Done ():
193+ t .Fail ()
194+
195+ case _ ,ok := <- containersCh :
196+ require .False (t ,ok ,"channel is expected to be closed" )
197+ }
198+ })
199+
200+ t .Run ("CoderdWebSocketCanHandleAgentClosing" ,func (t * testing.T ) {
66201t .Parallel ()
67202
68203// This test ensures that the agent containers `/watch` websocket can gracefully