@@ -59,10 +59,145 @@ func (fakeAgentProvider) Close() error {
5959 return 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 ) {
6372 t .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 ) {
66201 t .Parallel ()
67202
68203 // This test ensures that the agent containers `/watch` websocket can gracefully
0 commit comments