Files
ctrld/cmd/cli/change_logger.go
T

206 lines
5.7 KiB
Go

package cli
import (
"maps"
"slices"
"sync"
"tailscale.com/net/netmon"
"time"
)
// noiseSummaryInterval bounds a storm of noise deltas to one journal line,
// because AirDrop and virtual adapters can churn for hours.
const noiseSummaryInterval = 10 * time.Minute
// wakeReportInterval merges the reports of the wake sources. netmon polls for
// a time jump every 15 s, so one wake can reach ctrld over half a minute.
const wakeReportInterval = 30 * time.Second
// repeatLogger holds the last value of each named line, so a line that
// repeats every few seconds reaches the log on change only.
type repeatLogger struct {
mu sync.Mutex
last map[string]string
repeats map[string]uint64
}
// changed reports whether the value of a key differs from the value before it.
// The repeat count tells how many lines the caller held back, so the next line
// it writes can carry them.
func (r *repeatLogger) changed(key, value string) (changed bool, repeats uint64) {
r.mu.Lock()
defer r.mu.Unlock()
if r.last == nil {
r.last = make(map[string]string)
r.repeats = make(map[string]uint64)
}
if previous, seen := r.last[key]; seen && previous == value {
r.repeats[key]++
return false, r.repeats[key]
}
repeats = r.repeats[key]
r.last[key] = value
r.repeats[key] = 0
return true, repeats
}
// wakeHoldInterval is the time that a wake report without a gap waits for the
// detector. The detector ticks every two seconds, so it reports well inside
// this hold when it runs.
const wakeHoldInterval = 8 * time.Second
// wakeReport is one wake as a source saw it.
type wakeReport struct {
source string
gap time.Duration
state *netmon.State
}
// wakeReporter keeps one report per wake. netmon measures no gap, so its
// report waits for the detector, which measures one. When no detector report
// comes, the held report goes out as it is.
type wakeReporter struct {
mu sync.Mutex
last time.Time
held *wakeReport
// after arms the hold timer. nil means time.AfterFunc, and a test fires
// the hold itself.
after func(time.Duration, func())
}
// note takes one report of a wake and hands the report that the journal gets
// to emit. The first source of a window reports. A report with a gap replaces
// a held report without one. Every other report of the window is the same
// wake and drops.
func (w *wakeReporter) note(now time.Time, report wakeReport, emit func(wakeReport)) {
w.mu.Lock()
defer w.mu.Unlock()
gapKnown := report.gap > 0
if !w.last.IsZero() && now.Sub(w.last) < wakeReportInterval {
if w.held != nil && gapKnown {
w.held = nil
emit(report)
}
return
}
w.last = now
if gapKnown {
w.held = nil
emit(report)
return
}
held := &wakeReport{source: report.source, gap: report.gap, state: report.state}
w.held = held
w.afterFunc()(wakeHoldInterval, func() { w.release(held, emit) })
}
// release emits a held report when no source with a gap replaced it.
func (w *wakeReporter) release(held *wakeReport, emit func(wakeReport)) {
w.mu.Lock()
defer w.mu.Unlock()
if w.held != held {
return
}
w.held = nil
emit(*held)
}
func (w *wakeReporter) afterFunc() func(time.Duration, func()) {
if w.after != nil {
return w.after
}
return func(d time.Duration, fn func()) { time.AfterFunc(d, fn) }
}
// noiseSummary describes the noise deltas that one journal line covers.
type noiseSummary struct {
Count int
First time.Time
Last time.Time
Interfaces []string
}
// noiseCoalescer counts the noise deltas of a storm and reports when the
// journal needs a line for them. The count, the first time, and the names
// cover the open window; last covers the storm, so a quiet period ends it.
type noiseCoalescer struct {
mu sync.Mutex
count int
first time.Time
last time.Time
emitted time.Time
names map[string]struct{}
}
// add records one noise delta and reports whether to log a summary now. The
// summary covers the deltas since the last one, so a storm of any length
// keeps to one line per interval.
func (c *noiseCoalescer) add(interfaces []string, now time.Time) (summary noiseSummary, emit bool) {
c.mu.Lock()
defer c.mu.Unlock()
newRun := c.stormEnded(now)
if newRun {
c.clearWindow()
}
c.record(interfaces, now)
if !newRun && now.Sub(c.emitted) < noiseSummaryInterval {
return noiseSummary{}, false
}
summary = c.summary()
c.emitted = now
c.clearWindow()
return summary, true
}
// flush reports the open window and closes it. A storm ends with a delta that
// the daemon acts on, so the last window of the storm reaches the journal
// there instead of waiting for the next storm.
func (c *noiseCoalescer) flush(now time.Time) (summary noiseSummary, emit bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.count == 0 {
return noiseSummary{}, false
}
summary = c.summary()
c.emitted = now
c.clearWindow()
return summary, true
}
// stormEnded is true when the deltas stopped for longer than the interval, so
// a new storm does not inherit the count of the storm before it.
func (c *noiseCoalescer) stormEnded(now time.Time) bool {
return c.last.IsZero() || now.Sub(c.last) > noiseSummaryInterval
}
func (c *noiseCoalescer) record(interfaces []string, now time.Time) {
if c.count == 0 {
c.first = now
}
c.count++
c.last = now
if c.names == nil {
c.names = make(map[string]struct{})
}
for _, name := range interfaces {
c.names[name] = struct{}{}
}
}
// summary sorts the names, because a log line that keeps its order reads as
// one value across a capture.
func (c *noiseCoalescer) summary() noiseSummary {
return noiseSummary{
Count: c.count,
First: c.first,
Last: c.last,
Interfaces: slices.Sorted(maps.Keys(c.names)),
}
}
func (c *noiseCoalescer) clearWindow() {
c.count = 0
c.first = time.Time{}
c.names = nil
}