@@ -51,9 +51,13 @@ type Monitor struct {
5151 lastScreenshotAt atomic.Int64 // unix millis of last capture
5252 screenshotFn func (ctx context.Context , displayNum int ) ([]byte , error ) // nil → real ffmpeg
5353
54+ asyncWg sync.WaitGroup // tracks in-flight goroutines (fetchResponseBody, enableDomains, etc.)
55+ restartMu sync.Mutex // serializes handleUpstreamRestart to prevent overlapping reconnects
56+
5457 lifecycleCtx context.Context // cancelled on Stop()
5558 cancel context.CancelFunc
5659 done chan struct {}
60+ readReady chan struct {} // closed when readLoop has started reading
5761
5862 running atomic.Bool
5963}
@@ -112,13 +116,18 @@ func (m *Monitor) Start(parentCtx context.Context) error {
112116 m .lifecycleCtx = ctx
113117 m .cancel = cancel
114118 m .done = make (chan struct {})
119+ m .readReady = make (chan struct {})
115120 m .lifeMu .Unlock ()
116121
117122 m .running .Store (true )
118123
119124 go m .readLoop (ctx )
120125 go m .subscribeToUpstream (ctx )
121- go m .initSession (ctx ) // must run after readLoop starts
126+ m .asyncWg .Add (1 )
127+ go func () {
128+ defer m .asyncWg .Done ()
129+ m .initSession (ctx )
130+ }()
122131
123132 return nil
124133}
@@ -140,6 +149,10 @@ func (m *Monitor) Stop() {
140149 <- done
141150 }
142151
152+ // Wait for all in-flight async goroutines (fetchResponseBody, enableDomains,
153+ // screenshots) to finish before closing the connection they may be writing to.
154+ m .asyncWg .Wait ()
155+
143156 m .lifeMu .Lock ()
144157 if m .conn != nil {
145158 _ = m .conn .Close (websocket .StatusNormalClosure , "stopped" )
@@ -165,7 +178,7 @@ func (m *Monitor) clearState() {
165178
166179 m .failPendingCommands ()
167180
168- m .computed .resetOnNavigation ()
181+ m .computed .resetOnNavigation (0 )
169182}
170183
171184// failPendingCommands unblocks all in-flight send() calls by delivering an
@@ -194,13 +207,17 @@ func (m *Monitor) readLoop(ctx context.Context) {
194207 m .lifeMu .Lock ()
195208 done := m .done
196209 conn := m .conn
210+ readReady := m .readReady
197211 m .lifeMu .Unlock ()
198212 defer close (done )
199213
200214 if conn == nil {
201215 return
202216 }
203217
218+ // Signal that readLoop is ready to receive responses.
219+ close (readReady )
220+
204221 for {
205222 _ , b , err := conn .Read (ctx )
206223 if err != nil {
@@ -284,7 +301,16 @@ func (m *Monitor) send(ctx context.Context, method string, params any, sessionID
284301
285302// initSession enables CDP domains, injects the interaction-tracking script,
286303// and manually attaches to any targets already open when the monitor started.
304+ // It waits for readLoop to be ready before sending any commands.
287305func (m * Monitor ) initSession (ctx context.Context ) {
306+ m .lifeMu .Lock ()
307+ readReady := m .readReady
308+ m .lifeMu .Unlock ()
309+ select {
310+ case <- readReady :
311+ case <- ctx .Done ():
312+ return
313+ }
288314 _ , _ = m .send (ctx , "Target.setAutoAttach" , map [string ]any {
289315 "autoAttach" : true ,
290316 "waitForDebuggerOnStart" : false ,
@@ -324,7 +350,9 @@ func (m *Monitor) attachExistingTargets(ctx context.Context) {
324350 if alreadyAttached {
325351 continue
326352 }
353+ m .asyncWg .Add (1 )
327354 go func (targetID string ) {
355+ defer m .asyncWg .Done ()
328356 res , err := m .send (ctx , "Target.attachToTarget" , map [string ]any {
329357 "targetId" : targetID ,
330358 "flatten" : true ,
@@ -344,18 +372,26 @@ func (m *Monitor) attachExistingTargets(ctx context.Context) {
344372}
345373
346374// restartReadLoop waits for the current readLoop to exit, then starts a new one.
347- func (m * Monitor ) restartReadLoop (ctx context.Context ) {
375+ // Returns false if the context was cancelled before the restart completed.
376+ func (m * Monitor ) restartReadLoop (ctx context.Context ) bool {
348377 m .lifeMu .Lock ()
349378 done := m .done
350379 m .lifeMu .Unlock ()
351380
352- <- done
381+ // Wait for old readLoop, but bail if context is cancelled (e.g. Stop called).
382+ select {
383+ case <- done :
384+ case <- ctx .Done ():
385+ return false
386+ }
353387
354388 m .lifeMu .Lock ()
355389 m .done = make (chan struct {})
390+ m .readReady = make (chan struct {})
356391 m .lifeMu .Unlock ()
357392
358393 go m .readLoop (ctx )
394+ return true
359395}
360396
361397// subscribeToUpstream reconnects with backoff on Chrome restarts, publishing disconnect/reconnect events.
@@ -377,8 +413,15 @@ func (m *Monitor) subscribeToUpstream(ctx context.Context) {
377413}
378414
379415// handleUpstreamRestart tears down the old connection, reconnects with backoff,
380- // and re-initializes the CDP session.
416+ // and re-initializes the CDP session. Serialized by restartMu to prevent
417+ // overlapping reconnects from rapid successive Chrome restarts.
381418func (m * Monitor ) handleUpstreamRestart (ctx context.Context , newURL string ) {
419+ m .restartMu .Lock ()
420+ defer m .restartMu .Unlock ()
421+
422+ if ctx .Err () != nil {
423+ return
424+ }
382425 m .publish (events.Event {
383426 Ts : time .Now ().UnixMilli (),
384427 Type : EventMonitorDisconnected ,
@@ -403,8 +446,14 @@ func (m *Monitor) handleUpstreamRestart(ctx context.Context, newURL string) {
403446 return
404447 }
405448
406- m .restartReadLoop (ctx )
407- go m .initSession (ctx )
449+ if ! m .restartReadLoop (ctx ) {
450+ return
451+ }
452+ m .asyncWg .Add (1 )
453+ go func () {
454+ defer m .asyncWg .Done ()
455+ m .initSession (ctx )
456+ }()
408457
409458 m .publish (events.Event {
410459 Ts : time .Now ().UnixMilli (),
0 commit comments