mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
e00e85765b
This PR merges code from `coder/aibridge` repository into `coder/coder`. It was split into 4 PRs for easier review but stacked PRs will need to be merged into this PR so all checks pass. * https://github.com/coder/coder/pull/24190 -> raw code copy (this PR, before merging PRs on top of it, it was just 1 commit: https://github.com/coder/coder/commit/70d33f33200c7e77df910957595715f81f9bec24) * https://github.com/coder/coder/pull/24570 -> update imports in `coder/coder` to use copied code * https://github.com/coder/coder/pull/24586 -> linter fixes and CI integration (also added README.md) * https://github.com/coder/coder/pull/24571 -> added exclude to scripts/check_emdash.sh check Original PR message (before PR squash): Moves coder/aibridge code into coder/coder repository. Omitted files: - `go.mod`, `go.sum`, `.gitignore`, `.github/workflows/ci.yml,` `Makefile`, `LICENSE`, `README.md` (modified README.md is added later) - `.github`, `example`, `buildinfo,` `scripts` directories Simple verification script (will list omitted files) ``` tmp=$(mktemp -d) echo "$tmp" git clone --depth=1 https://github.com/coder/aibridge "$tmp/aibridge" git clone --depth=1 --branch pb/aibridge-code-move https://github.com/coder/coder "$tmp/coder" diff -rq --exclude=.git "$tmp/aibridge" "$tmp/coder/aibridge" # rm -rf "$tmp" ```
267 lines
6.8 KiB
Go
267 lines
6.8 KiB
Go
package eventstream
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
var ErrEventStreamClosed = xerrors.New("event stream closed")
|
|
|
|
const (
|
|
pingInterval = time.Second * 10
|
|
// SlowFlushThreshold is the duration after which a flush to the client is
|
|
// considered slow and a warning is logged.
|
|
SlowFlushThreshold = time.Millisecond * 500
|
|
)
|
|
|
|
type event []byte
|
|
|
|
type EventStream struct {
|
|
ctx context.Context
|
|
logger slog.Logger
|
|
clk quartz.Clock
|
|
|
|
pingPayload []byte
|
|
|
|
initiated atomic.Bool
|
|
initiateOnce sync.Once
|
|
|
|
shutdownOnce sync.Once
|
|
eventsCh chan event
|
|
|
|
// doneCh is closed when the start loop exits.
|
|
doneCh chan struct{}
|
|
|
|
// tick sends periodic pings to keep the connection alive.
|
|
tick *time.Ticker
|
|
}
|
|
|
|
// NewEventStream creates a new SSE stream, with an optional payload which is used to send pings every [pingInterval].
|
|
func NewEventStream(ctx context.Context, logger slog.Logger, pingPayload []byte, clk quartz.Clock) *EventStream {
|
|
// Send periodic pings to keep connections alive.
|
|
// The upstream provider may also send their own pings, but we can't rely on this.
|
|
tick := time.NewTicker(time.Nanosecond)
|
|
tick.Stop() // Ticker will start after stream initiation.
|
|
|
|
return &EventStream{
|
|
ctx: ctx,
|
|
logger: logger,
|
|
clk: clk,
|
|
|
|
pingPayload: pingPayload,
|
|
|
|
eventsCh: make(chan event, 128), // Small buffer to unblock senders; once full, senders will block.
|
|
doneCh: make(chan struct{}),
|
|
tick: tick,
|
|
}
|
|
}
|
|
|
|
// InitiateStream initiates the SSE stream by sending headers and starting the
|
|
// ping ticker. This is safe to call multiple times as only the first call has
|
|
// any effect.
|
|
func (s *EventStream) InitiateStream(w http.ResponseWriter) {
|
|
s.initiateOnce.Do(func() {
|
|
s.initiated.Store(true)
|
|
s.logger.Debug(s.ctx, "stream initiated")
|
|
|
|
// Send headers for Server-Sent Event stream.
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
// Send initial flush to ensure connection is established.
|
|
if err := flush(w); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Start ping ticker.
|
|
s.tick.Reset(pingInterval)
|
|
})
|
|
}
|
|
|
|
// Start handles sending Server-Sent Event to the client.
|
|
func (s *EventStream) Start(w http.ResponseWriter, r *http.Request) {
|
|
// Signal completion on exit so senders don't block indefinitely after closure.
|
|
defer close(s.doneCh)
|
|
|
|
ctx := r.Context()
|
|
|
|
defer s.tick.Stop()
|
|
|
|
for {
|
|
var (
|
|
ev event
|
|
open bool
|
|
)
|
|
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
case <-ctx.Done():
|
|
s.logger.Debug(ctx, "request context canceled", slog.Error(ctx.Err()))
|
|
return
|
|
case ev, open = <-s.eventsCh: // Once closed, the buffered channel will drain all buffered values before showing as closed.
|
|
if !open {
|
|
s.logger.Debug(ctx, "events channel closed")
|
|
return
|
|
}
|
|
|
|
// Initiate the stream on first event (if not already initiated).
|
|
s.InitiateStream(w)
|
|
case <-s.tick.C:
|
|
ev = s.pingPayload
|
|
if ev == nil {
|
|
continue
|
|
}
|
|
}
|
|
|
|
_, err := w.Write(ev)
|
|
if err != nil {
|
|
if IsConnError(err) {
|
|
s.logger.Debug(ctx, "client disconnected during SSE write", slog.Error(err))
|
|
} else {
|
|
s.logger.Warn(ctx, "failed to write SSE event", slog.Error(err))
|
|
}
|
|
return
|
|
}
|
|
flushStart := s.clk.Now()
|
|
if err := flush(w); err != nil {
|
|
s.logger.Warn(ctx, "failed to flush event stream", slog.Error(err))
|
|
return
|
|
}
|
|
if d := s.clk.Since(flushStart); d > SlowFlushThreshold {
|
|
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
|
s.logger.Warn(ctx, "slow client detected",
|
|
slog.F("flush_duration", d),
|
|
slog.F("client_ip", clientIP),
|
|
slog.F("user_agent", r.Header.Get("User-Agent")),
|
|
slog.F("payload_size", len(ev)),
|
|
)
|
|
}
|
|
|
|
// Reset the timer once we've flushed some data to the stream, since it's already fresh.
|
|
// No need to ping in that case.
|
|
s.tick.Reset(pingInterval)
|
|
}
|
|
}
|
|
|
|
// Send enqueues an event in a non-blocking fashion, but if the channel is full
|
|
// then it will block.
|
|
func (s *EventStream) Send(ctx context.Context, payload []byte) error {
|
|
// Save an unnecessary marshaling if possible.
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-s.ctx.Done():
|
|
return s.ctx.Err()
|
|
case <-s.doneCh:
|
|
return ErrEventStreamClosed
|
|
default:
|
|
}
|
|
|
|
return s.SendRaw(ctx, payload)
|
|
}
|
|
|
|
func (s *EventStream) SendRaw(ctx context.Context, payload []byte) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-s.ctx.Done():
|
|
return s.ctx.Err()
|
|
case <-s.doneCh:
|
|
return ErrEventStreamClosed
|
|
case s.eventsCh <- payload:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the stream, sending any supplementary events downstream if required.
|
|
// ONLY call this once all events have been submitted.
|
|
func (s *EventStream) Shutdown(shutdownCtx context.Context) error {
|
|
s.shutdownOnce.Do(func() {
|
|
s.logger.Debug(shutdownCtx, "shutdown initiated", slog.F("outstanding_events", len(s.eventsCh)))
|
|
|
|
// Now it is safe to close the events channel; the Start() loop will exit
|
|
// after draining remaining events and receivers will stop ranging.
|
|
close(s.eventsCh)
|
|
})
|
|
|
|
var err error
|
|
select {
|
|
case <-shutdownCtx.Done():
|
|
// If shutdownCtx completes, shutdown likely exceeded its timeout.
|
|
err = xerrors.Errorf("shutdown ended prematurely with %d outstanding events: %w", len(s.eventsCh), shutdownCtx.Err())
|
|
case <-s.ctx.Done():
|
|
err = xerrors.Errorf("shutdown ended prematurely with %d outstanding events: %w", len(s.eventsCh), s.ctx.Err())
|
|
case <-s.doneCh:
|
|
return nil
|
|
}
|
|
|
|
// Even if the context is canceled, we need to wait for Start() to complete.
|
|
<-s.doneCh
|
|
return err
|
|
}
|
|
|
|
// IsStreaming checks if the stream has been initiated, or
|
|
// when events are buffered which - when processed - will initiate the stream.
|
|
func (s *EventStream) IsStreaming() bool {
|
|
return s.initiated.Load() || len(s.eventsCh) > 0
|
|
}
|
|
|
|
// IsConnError checks if an error is related to client disconnection or context cancellation.
|
|
func IsConnError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
if errors.Is(err, io.EOF) {
|
|
return true
|
|
}
|
|
|
|
if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || errors.Is(err, net.ErrClosed) {
|
|
return true
|
|
}
|
|
|
|
errStr := err.Error()
|
|
return strings.Contains(errStr, "broken pipe") ||
|
|
strings.Contains(errStr, "connection reset by peer")
|
|
}
|
|
|
|
func IsUnrecoverableError(err error) bool {
|
|
if errors.Is(err, context.Canceled) {
|
|
return true
|
|
}
|
|
|
|
return IsConnError(err)
|
|
}
|
|
|
|
func flush(w http.ResponseWriter) (err error) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok || flusher == nil {
|
|
return xerrors.New("SSE not supported")
|
|
}
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil { //nolint:revive,staticcheck // Intentionally swallowed; likely a broken connection.
|
|
}
|
|
}()
|
|
|
|
flusher.Flush()
|
|
return nil
|
|
}
|