wavetermdev/waveterm · error

client is done

Error message

client is done

What it means

SendSSEvent broadcasts a server-sent event to all registered SSE channels of a ClientImpl. Before doing so it checks c.GetIsDone(); if the client has been marked done (shut down / disconnected), the event is refused with "client is done". The library throws this to prevent sending events through a client whose lifecycle has ended. It applies to SendSSEvent directly and to callers like SendAsyncInitiation, SendTermWrite, and ShowModal.

Source

Thrown at tsunami/engine/clientimpl.go:284

	ch := make(chan ssEvent, 100)
	c.SSEChannels[connectionId] = ch
	return ch
}

func (c *ClientImpl) UnregisterSSEChannel(connectionId string) {
	c.SSEChannelsLock.Lock()
	defer c.SSEChannelsLock.Unlock()

	if ch, exists := c.SSEChannels[connectionId]; exists {
		close(ch)
		delete(c.SSEChannels, connectionId)
	}
}

func (c *ClientImpl) SendSSEvent(event ssEvent) error {
	if c.GetIsDone() {
		return fmt.Errorf("client is done")
	}

	c.SSEChannelsLock.Lock()
	defer c.SSEChannelsLock.Unlock()

	// Send to all registered SSE channels
	for _, ch := range c.SSEChannels {
		select {
		case ch <- event:
			// Successfully sent
		default:
			// silently drop (below is just for debugging).  this wont happen in general
			// log.Printf("SSEvent channel is full for connection %s, skipping event", connectionId)
		}
	}

	return nil
}

View on GitHub (pinned to a4447c1563)

Solutions

  1. Check c.GetIsDone() before pushing events and drop/stop the work if true
  2. Ensure the client lifecycle owner signals done only after all pending SendTermWrite/ShowModal calls complete
  3. If reconnects happen, re-look-up the current live client instead of caching an old ClientImpl reference
  4. Handle the returned error gracefully — the event is lost; do not retry on the done client

Example fix

// before
err := client.SendTermWrite(refId, data)
if err != nil { log.Fatal(err) }
// after
if client.GetIsDone() { return } // client torn down; skip write
if err := client.SendTermWrite(refId, data); err != nil {
    log.Printf("dropped termwrite after done: %v", err)
}
Defensive patterns

Strategy: try-catch

Validate before calling

if client.GetIsDone() {
    // skip send; client lifecycle has ended
}

Type guard

func isClientLive(c *engine.ClientImpl) bool { return c != nil && !c.GetIsDone() }

Try / catch

if err := client.SendTermWrite(refId, data); err != nil {
    if client.GetIsDone() {
        log.Printf("client done, dropping event: %v", err)
        return nil // expected during shutdown, not fatal
    }
    return err
}

Prevention

When it happens

Trigger: Calling SendTermWrite, SendAsyncInitiation, ShowModal, or SendSSEvent after the client has been marked done (connection closed, client shutdown, or client.IsDone set). Race where the client finishes while another goroutine is still pushing events.

Common situations: Writing to a terminal block after the frontend tab/block was closed; firing async-initiation events after the client teardown goroutine ran; background workers holding a stale client reference after reconnect (new ClientImpl created but old one still used).

Related errors


AI-assisted analysis of wavetermdev/waveterm@a4447c1563 (2026-09-01). Data as JSON: /api/errors/070f2871520e27e6. Report an issue: GitHub.