From 636081ea7354124c3b33f5cbe21599362d136ff3 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 15:03:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(engine):=20scanner=20=E4=B8=8E=20s?= =?UTF-8?q?tore=20=E7=9A=84=E7=94=9F=E4=BA=A7=E4=BB=A3=E7=A0=81=E9=87=8D?= =?UTF-8?q?=E8=BF=B0=EF=BC=8C=E6=97=B6=E5=BA=8F=E4=B8=8E=E9=94=81=E4=B8=8D?= =?UTF-8?q?=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扫描循环的注释、keyed 字面量字段与局部命名重述,Wake 的两处非阻塞通知 收成 nudge;scannedSince 的持锁段抽成 cachedCommittedAfter。计时常量、 select/timer 语义、锁的获取顺序与提交字段顺序均未改变。store 把清理 SQL 提为常量,迁移行集的关闭点随所有权下移。 Co-Authored-By: Claude Code --- engine/internal/scanner/attribute.go | 47 +++++---- engine/internal/scanner/config.go | 6 +- engine/internal/scanner/health.go | 38 ++++--- engine/internal/scanner/loop.go | 148 +++++++++++++++------------ engine/internal/scanner/stats.go | 39 +++---- engine/internal/store/aliases.go | 8 +- engine/internal/store/migrate.go | 6 +- 7 files changed, 165 insertions(+), 127 deletions(-) diff --git a/engine/internal/scanner/attribute.go b/engine/internal/scanner/attribute.go index 1d9f635a..9f2e3e15 100644 --- a/engine/internal/scanner/attribute.go +++ b/engine/internal/scanner/attribute.go @@ -45,17 +45,25 @@ type attribution struct { identities map[string]string } -// SetStore installs the store after construction. The daemon does it from -// Serve, once the database is open; a nil store leaves the loop unpersisted. +// SetStore installs the store after construction. The daemon calls it from +// Serve once the database is open; leaving the store nil keeps the loop +// unpersisted. func (l *Loop) SetStore(s Store) { if s == nil { return } l.attr.mu.Lock() defer l.attr.mu.Unlock() + l.adoptStoreLocked(s) +} + +// adoptStoreLocked points the loop at s and forgets everything cached from the +// store it used before: acknowledgements and identity rows belong to the store +// that recorded them, not to the one replacing it. Caller holds the attribution +// lock. +func (l *Loop) adoptStoreLocked(s Store) { l.opts.Store = s l.attr.seeded = false - // Acknowledgements and identity rows belong to this store, not the last one. l.attr.roots = make(map[string]bool) l.attr.identities = make(map[string]string) } @@ -343,22 +351,22 @@ func (l *Loop) loadAliases(st Store) { l.attr.index.SetAliases(nil) return } - aliases, err := st.GroupAliases() + names, err := st.GroupAliases() if err != nil { l.opts.Logger.Warn("reading project aliases", "error", err) return } - l.attr.index.SetAliases(aliases) + l.attr.index.SetAliases(names) } // registry is the run registry to attribute with: the one `oberth start` -// installed, or the attribution the scanner already put on the port itself. +// installed, or else the attribution the scanner already put on the port +// itself. func (l *Loop) registry() groups.Registry { - if l.opts.Runs == nil { - return groups.PortRuns{} - } - if r := l.opts.Runs(); r != nil { - return r + if l.opts.Runs != nil { + if r := l.opts.Runs(); r != nil { + return r + } } return groups.PortRuns{} } @@ -463,9 +471,9 @@ func (l *Loop) recordIdentityRoot(st Store, root string) { l.attr.identities[identity] = "" } -// record writes the port transitions of one published delta to the history -// ring. Only the three port-scoped kinds are persisted; health_changed and -// scan_error stay in-memory notifications. +// record writes the port transitions of one published delta into the history +// ring. Just the three port-scoped kinds are persisted; health_changed and +// scan_error remain in-memory notifications. func (l *Loop) record(events []state.Event) { st := l.store() if st == nil || len(events) == 0 { @@ -514,16 +522,19 @@ func historyRow(ev state.Event, at time.Time) (store.HistoryEvent, bool) { }, true } -// store returns the installed store under the attribution lock. +// store hands back the installed store while holding the attribution lock. func (l *Loop) store() Store { l.attr.mu.Lock() defer l.attr.mu.Unlock() return l.opts.Store } +// eventTime parses an RFC 3339 timestamp, falling back to the caller's instant +// when the field is missing or malformed. func eventTime(at string, fallback time.Time) time.Time { - if t, err := time.Parse(time.RFC3339, at); err == nil { - return t + parsed, err := time.Parse(time.RFC3339, at) + if err != nil { + return fallback } - return fallback + return parsed } diff --git a/engine/internal/scanner/config.go b/engine/internal/scanner/config.go index b93d9f60..de7f5ece 100644 --- a/engine/internal/scanner/config.go +++ b/engine/internal/scanner/config.go @@ -84,9 +84,9 @@ func (l *Loop) LoadConfig(path string) error { } // ReloadConfigs re-reads every known root: the ones in the store and the ones -// the index already holds. It picks up files created, edited and deleted since -// the daemon started, and returns how many valid configs remain plus the files -// that could not be used. +// the index already holds. Files created, edited or deleted since the daemon +// started are picked up here; the result is how many valid configs remain, plus +// the files that could not be used. func (l *Loop) ReloadConfigs() (int, []groups.InvalidConfig) { var ( valid int diff --git a/engine/internal/scanner/health.go b/engine/internal/scanner/health.go index ab032950..63f4f9d4 100644 --- a/engine/internal/scanner/health.go +++ b/engine/internal/scanner/health.go @@ -15,12 +15,12 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// maxHealthProbes bounds how many configured health checks run at once, the -// same ceiling the opt-in probe uses. +// maxHealthProbes is how many configured health checks may be in flight at +// once — the ceiling the opt-in probe already uses. const maxHealthProbes = ports.MaxProbes -// Probe is one health check. Options.Probe replaces it for tests; production -// is ports.ProbeHealth. +// Probe is one health check. Tests replace it through Options.Probe; +// production uses ports.ProbeHealth. type Probe func(host string, port int, path string, timeout time.Duration) ports.HealthResult // ProbeContext is a synchronous, context-aware health probe. It must return @@ -461,21 +461,29 @@ func commandEnv(port *int) []string { return append(env, "PORT="+v, "BERTH_PORT="+v) } -// preferHost picks the loopback address to probe. A service bound on IPv4 (or -// on every address) is reached at 127.0.0.1; one that only ever bound IPv6 is -// reached at [::1]. Picking per port rather than per row is what keeps a -// dual-stack listener from being probed twice with two different answers. +// preferHost picks the loopback address to probe: a service bound on IPv4 (or +// on every address) is reached at 127.0.0.1, one that only ever bound IPv6 at +// [::1]. Choosing per port instead of per row is what keeps a dual-stack +// listener from being probed twice with two different answers. func preferHost(current string, p state.Port) string { if current == "127.0.0.1" { return current } - switch p.BindAddress { - case "::", "::1", "[::]", "[::1]": - if current == "" { - return "[::1]" - } - return current - default: + if !ipv6OnlyBind(p.BindAddress) { return "127.0.0.1" } + if current == "" { + return "[::1]" + } + return current +} + +// ipv6OnlyBind reports whether an address a listener bound to can only be +// reached over IPv6. +func ipv6OnlyBind(bind string) bool { + switch bind { + case "::", "::1", "[::]", "[::1]": + return true + } + return false } diff --git a/engine/internal/scanner/loop.go b/engine/internal/scanner/loop.go index f3b01e9f..1ab1bc8c 100644 --- a/engine/internal/scanner/loop.go +++ b/engine/internal/scanner/loop.go @@ -87,14 +87,15 @@ const ( MinStatsInterval = 250 * time.Millisecond ) -// Include is the per-subscriber opt-in from `state.subscribe {include}`. +// Include is the per-subscriber opt-in that a `state.subscribe {include}` +// request carries. type Include struct { Stats bool Health bool } -// Demand reports what the daemon's subscribers currently want. Returning zero -// subscribers parks the loop until Wake is called. +// Demand reports what the daemon's subscribers currently want. Zero +// subscribers park the loop until Wake is called. type Demand func() (subscribers int, include Include) // Publisher receives every published change. prev is the snapshot the delta is @@ -114,13 +115,13 @@ type Options struct { Store Store // Runs returns the run registry group attribution consults. It is a - // function because `oberth start` installs the registry after the loop is - // built; nil, or a nil return, means groups.PortRuns{}. + // function because `oberth start` installs the registry only after the loop + // exists; nil, or a nil return, means groups.PortRuns{}. Runs func() groups.Registry - // Sessions builds the snapshot's `sessions` collection from the ports the + // Sessions builds the snapshot's `sessions` collection out of the ports the // tick just resolved. It is a function for the same reason Runs is: the - // daemon installs it from an OnStart hook, after the loop exists. Nil + // daemon installs it from an OnStart hook, once the loop exists. Nil // publishes an empty collection. Sessions func(ports []state.Port) []state.SessionRecord @@ -166,15 +167,16 @@ type Options struct { ScanInterval time.Duration // StatsInterval overrides the stats-only tick's cadence - // (`daemon.stats_interval`): zero means StatsInterval; anything below - // MinStatsInterval is clamped to it. + // (`daemon.stats_interval`): zero means StatsInterval, and anything under + // MinStatsInterval is clamped up to it. StatsInterval time.Duration - // Now overrides the clock, for tests. + // Now overrides the clock for tests. Now func() time.Time } -// Loop is the scan goroutine plus the cached snapshot RPC reads serve from. +// Loop is the scan goroutine plus the cached snapshot that RPC reads are +// served from. type Loop struct { opts Options now func() time.Time @@ -288,12 +290,12 @@ func New(opts Options) *Loop { opts: opts, now: opts.Now, base: base, + interval: base, wake: make(chan struct{}, 1), statsWake: make(chan struct{}, 1), runGate: make(chan struct{}, 1), - rpcGate: make(chan struct{}, 1), orderGate: make(chan struct{}, 1), - interval: base, + rpcGate: make(chan struct{}, 1), } l.attachDockerWatcher(base) return l @@ -438,19 +440,21 @@ func (l *Loop) ProbeContext(ctx context.Context, host string, port int, path str return result } -// SetDemand installs the demand callback after construction. The daemon uses it -// because the server and the loop refer to each other. +// SetDemand installs the demand callback after construction. The daemon needs +// the setter because the server and the loop refer to each other. func (l *Loop) SetDemand(d Demand) { - if d != nil { - l.opts.Demand = d + if d == nil { + return } + l.opts.Demand = d } -// SetPublisher installs the publish callback after construction. +// SetPublisher installs the publish callback after the loop is built. func (l *Loop) SetPublisher(p Publisher) { - if p != nil { - l.opts.Publish = p + if p == nil { + return } + l.opts.Publish = p } // Cached returns the last published snapshot without scanning. @@ -466,12 +470,15 @@ func (l *Loop) Cached() state.Snapshot { // Wake nudges a stopped or backed-off loop to scan now. Called when a // subscriber connects or an RPC reads state. func (l *Loop) Wake() { + nudge(l.wake) + nudge(l.statsWake) +} + +// nudge signals ch without blocking. A signal the buffer already holds is left +// alone, so a burst of callers costs one wake-up rather than a queue of them. +func nudge(ch chan struct{}) { select { - case l.wake <- struct{}{}: - default: - } - select { - case l.statsWake <- struct{}{}: + case ch <- struct{}{}: default: } } @@ -553,7 +560,8 @@ func (l *Loop) waitTick(ctx context.Context, timer *time.Timer, include Include) case <-timer.C: return false case <-l.wake: - // A read or a new subscriber: back to the base interval. + // Somebody is reading or has just subscribed: drop back to the base + // interval rather than serve them a backed-off cadence. l.mu.Lock() l.interval = l.base l.mu.Unlock() @@ -566,14 +574,14 @@ func (l *Loop) waitTick(ctx context.Context, timer *time.Timer, include Include) // immediately and spin; draining first is what makes Reset safe. Stop reports // whether it stopped the timer, and only then is there certainly nothing to // drain. -func armTimer(timer *time.Timer, wait time.Duration) { +func armTimer(timer *time.Timer, delay time.Duration) { if !timer.Stop() { select { case <-timer.C: default: } } - timer.Reset(wait) + timer.Reset(delay) } // waitForDemand answers what this tick should collect, and whether it should @@ -920,15 +928,14 @@ func (l *Loop) superseded(seq uint64) bool { return l.haveSnap && seq < l.committedSeq } -// commitScan swaps a finished scan into the cache and adapts the interval. -// Caller holds the ordering gate and commitMu; this takes l.mu for the swap -// itself. +// commitScan swaps a finished scan into the cache and adapts the interval. The +// caller holds the ordering gate and commitMu; the swap itself takes l.mu. func (l *Loop) commitScan(c commit) (next, prev state.Snapshot, changed bool) { l.mu.Lock() defer l.mu.Unlock() - l.subs = c.subs prev = l.snap + l.subs = c.subs rows, groupRows := c.rows, c.groups l.scans++ @@ -1001,8 +1008,8 @@ func (l *Loop) storeCommitted(prev state.Snapshot, rows []state.Port, groupRows } l.seq++ next.Seq = l.seq - l.snap = next l.haveSnap = true + l.snap = next l.interval = l.base return next, prev, true } @@ -1042,14 +1049,15 @@ func (l *Loop) healthDue() bool { // than that, and 5x when the daemon only answers RPC reads. At the default 2 s // base those are the 5 s and 10 s of the contract. Caller holds the mutex. func (l *Loop) maxIntervalLocked() time.Duration { - factor := float64(IdleMaxFactor) + scale := float64(IdleMaxFactor) if l.subs > 0 { - factor = SubscribedMaxFactor + scale = SubscribedMaxFactor } - return time.Duration(float64(l.base) * factor) + return time.Duration(float64(l.base) * scale) } -// backoff multiplies the interval by 1.5, floored at base and capped at max. +// backoff multiplies the interval by 1.5, with base as its floor and max as its +// ceiling. func backoff(d, base, max time.Duration) time.Duration { next := time.Duration(float64(d) * BackoffFactor) switch { @@ -1215,18 +1223,18 @@ func portTransitionEvents(prev, next state.Snapshot, at string, d state.Delta) [ groups = append(groups, next.Groups...) removed := keySet(d.Ports.Removed) events := make([]state.Event, 0, len(d.Ports.Added)+len(d.Ports.Removed)) - restarted := make(map[string]bool, len(d.Ports.Added)) + restartedKeys := make(map[string]bool, len(d.Ports.Added)) for i := range d.Ports.Added { p := d.Ports.Added[i] kind := "port_up" if removed[p.Key()] { - kind, restarted[p.Key()] = "port_restarted", true + kind, restartedKeys[p.Key()] = "port_restarted", true } events = append(events, eventForPort(next.Seq, kind, at, p, groups)) } before := indexPorts(prev.Ports) for _, key := range d.Ports.Removed { - if restarted[key] { + if restartedKeys[key] { continue } events = append(events, eventForPort(next.Seq, "port_down", at, before[key], groups)) @@ -1501,13 +1509,13 @@ func (l *Loop) Republish() error { // RPC cache TTL and `daemon status` read exactly what they would have if the // write had never happened. Caller holds the ordering gate and commitMu. func (l *Loop) commitRepublish( - subs int, carry Include, + subscribers int, carry Include, rows []state.Port, groupRows []state.Group, sessionRows []state.SessionRecord, ) (next, prev state.Snapshot, changed bool) { l.mu.Lock() defer l.mu.Unlock() - l.subs = subs + l.subs = subscribers prev = l.snap carryHealth(prev.Ports, rows, true) @@ -1667,30 +1675,37 @@ func (l *Loop) beginScan() (generation, refresh uint64) { return l.scanSeq, l.refreshRequested } -// scanGeneration is the number of the last scan to have begun. +// scanGeneration is the number of the scan that began most recently. func (l *Loop) scanGeneration() uint64 { l.mu.Lock() defer l.mu.Unlock() return l.scanSeq } -// scannedSince reports the cached snapshot when a scan that began strictly -// after generation gen has already committed one carrying what the caller -// asked for. +// scannedSince serves the cached snapshot when a scan that began strictly after +// generation gen has already committed one, and that snapshot carries whatever +// the caller asked for. // -// Strictly after, and counted rather than timed: a scan numbered gen is the one -// that was already running (or had just finished) when the caller asked, and -// serving its result would be serving state from before the call. That -// distinction is invisible to `time.Now` on a platform whose clock moves in -// milliseconds, which is how a kill's rescan came to be skipped on Windows and -// nowhere else. +// Strictly after, and counted rather than timed: the scan numbered gen is the +// one that was already running (or had just finished) when the caller asked, so +// serving its result would be serving state from before the call. A clock +// cannot draw that line — `time.Now` moves in milliseconds on some platforms, +// which is how a kill's rescan came to be skipped on Windows and nowhere else. // -// Health never coalesces either way: the probes run on their own cadence, so -// "a scan happened" says nothing about whether it probed. +// Health never coalesces either way: the probes keep their own cadence, so "a +// scan happened" says nothing about whether it probed. func (l *Loop) scannedSince(gen uint64, include Include) (state.Snapshot, bool) { if include.Health { return state.Snapshot{}, false } + return l.cachedCommittedAfter(gen, include) +} + +// cachedCommittedAfter is the rest of scannedSince. It takes l.mu itself and +// reports the cached snapshot only when the scan behind it began strictly after +// gen, finished clean, nothing has invalidated it since, and include is +// covered. +func (l *Loop) cachedCommittedAfter(gen uint64, include Include) (state.Snapshot, bool) { l.mu.Lock() defer l.mu.Unlock() if !l.haveSnap || l.lastErr != nil || l.committedSeq <= gen || l.refreshObserved != l.refreshRequested { @@ -1702,8 +1717,8 @@ func (l *Loop) scannedSince(gen uint64, include Include) (state.Snapshot, bool) return l.snap, true } -// cached returns the cached snapshot when it is younger than CacheTTL and -// already carries everything the caller asked for. +// cached is the read path's view of the cache: the snapshot, but only while it +// is younger than CacheTTL and covers what the caller asked for. func (l *Loop) cached(include Include) (state.Snapshot, bool) { return l.cachedWithin(include, CacheTTL) } @@ -1719,20 +1734,19 @@ func (l *Loop) cachedWithin(include Include, maxAge time.Duration) (state.Snapsh // demandInclude is what the subscribers want a published snapshot to keep. // -// A scan started by a read is published like any other, so it must not drop -// what a tick would have carried: without this an `oberth list` in the middle -// of a subscribed session would publish a snapshot with the stats stripped out, -// and every subscriber that opted into them would see them blink away and back. -// It used to be collected again, which put `docker stats` and a round of health -// probes on the critical path of every kill and every write; it is copied -// forward now instead. +// A read-driven scan is published like any other, so it must not drop what a +// tick would have carried: without this an `oberth list` in the middle of a +// subscribed session would publish a snapshot with the stats stripped out, and +// every subscriber that opted into them would watch them blink away and back. +// Carrying them forward is also what keeps `docker stats` and a round of health +// probes off the critical path of every kill and every write. func (l *Loop) demandInclude() Include { _, want := l.opts.Demand() return want } -// snapHasStats reports whether the cached snapshot carries stats. Caller holds -// the mutex. +// snapHasStats reports whether the cached snapshot carries stats at all. It +// reads l.snap, so the caller has to hold the mutex. func (l *Loop) snapHasStats() bool { for _, row := range l.snap.Ports { if row.Stats != nil { @@ -1768,14 +1782,14 @@ type Status struct { LastError error } -// Status returns the scanner's current counters. +// Status reports the scanner's current counters. func (l *Loop) Status() Status { l.mu.Lock() defer l.mu.Unlock() return Status{ - LastScanAt: l.lastScanAt, Scans: l.scans, Seq: l.seq, + LastScanAt: l.lastScanAt, IntervalMs: milliseconds(l.interval), BaseIntervalMs: milliseconds(l.base), StatsIntervalMs: milliseconds(l.statsInterval()), diff --git a/engine/internal/scanner/stats.go b/engine/internal/scanner/stats.go index 4604ce9d..fd478bc8 100644 --- a/engine/internal/scanner/stats.go +++ b/engine/internal/scanner/stats.go @@ -19,12 +19,11 @@ import ( // ports and useless for a load meter. Sampling them apart lets the meter run at // 1 s without pinning the daemon to a full `lsof`/`netstat` scan at that rate. // -// It parks whenever nothing is subscribed, so an unsubscribed daemon — -// answering `oberth list` and `daemon status` over RPC — samples nothing at -// all. +// It parks whenever nothing is subscribed, so a daemon that is only answering +// `oberth list` and `daemon status` over RPC samples nothing whatsoever. func (l *Loop) runStats(ctx context.Context) { - interval := l.statsInterval() - timer := time.NewTimer(interval) + cadence := l.statsInterval() + timer := time.NewTimer(cadence) defer timer.Stop() for ctx.Err() == nil { @@ -44,7 +43,7 @@ func (l *Loop) runStats(ctx context.Context) { l.sampleStatsContext(round, include) cancel() - armTimer(timer, interval) + armTimer(timer, cadence) select { case <-ctx.Done(): return @@ -53,16 +52,18 @@ func (l *Loop) runStats(ctx context.Context) { } } -// statsInterval is the cadence, clamped to something a machine can sustain. +// statsInterval is the cadence this loop samples at, clamped to something a +// machine can sustain. func (l *Loop) statsInterval() time.Duration { d := l.opts.StatsInterval - if d <= 0 { + switch { + case d <= 0: return StatsInterval - } - if d < MinStatsInterval { + case d < MinStatsInterval: return MinStatsInterval + default: + return d } - return d } // statsBasis is what one stats round was sampled against: the snapshot it read @@ -85,10 +86,10 @@ func (l *Loop) captureStatsBasis() (statsBasis, bool) { return statsBasis{snap: l.snap, scan: l.committedSeq, stats: l.statsScanSeq}, true } -// sampleStats runs one stats-only tick and publishes what moved. +// sampleStats runs a single stats-only tick and publishes whatever moved. // -// It never touches the scan interval, the scan counters or `lastScanAt`: the -// port scan's adaptive cadence, the RPC cache TTL and `daemon status` all read +// It leaves the scan interval, the scan counters and `lastScanAt` alone: the +// port scan's adaptive cadence, the RPC cache TTL and `daemon status` read // exactly what they read before this tick existed. func (l *Loop) sampleStats(include Include) { l.sampleStatsContext(context.Background(), include) @@ -393,11 +394,11 @@ func statsPIDs(pp []state.Port) []int { // Every other field is carried through untouched, which makes a stats delta // invisible to a subscriber that did not ask for stats. // -// A row the sample has nothing to say about keeps the stats it had: a pid that -// vanished between the scan and this tick is dropped from the sample, not -// zeroed on the wire, and the next port scan is what removes the row. -// `connections` is carried forward too — counting them is an `lsof` (or `ss`, -// or `netstat`) per port and stays on the scan tick. +// A row the sample says nothing about keeps the stats it already had: a pid +// that vanished between the scan and this tick is dropped from the sample +// rather than zeroed on the wire, and the next port scan is what removes the +// row. `connections` is carried forward as well — counting them costs an +// `lsof` (or `ss`, or `netstat`) per port and stays on the scan tick. func refreshStats(rows []state.Port, samples map[int]ports.ProcSample) []state.Port { return refreshStatsAgainst(rows, samples, nil) } diff --git a/engine/internal/store/aliases.go b/engine/internal/store/aliases.go index ac089a04..8b9e5368 100644 --- a/engine/internal/store/aliases.go +++ b/engine/internal/store/aliases.go @@ -40,16 +40,20 @@ func (s *Store) SetGroupAlias(root, name string) error { return s.exec(upsertGroupAlias, clean, name, nowString()) } +// clearGroupAlias drops the single row keyed by a main checkout root. Deleting +// an absent row is not an error. +const clearGroupAlias = `DELETE FROM group_aliases WHERE root = ?` + // ClearGroupAlias removes the alias for root; a missing row is not an error. func (s *Store) ClearGroupAlias(root string) error { clean, err := cleanRoot(root) if err != nil { return err } - return s.exec(`DELETE FROM group_aliases WHERE root = ?`, clean) + return s.exec(clearGroupAlias, clean) } -// GroupAliases returns every alias as main checkout root → project name. +// GroupAliases maps every main checkout root to the project name set for it. func (s *Store) GroupAliases() (map[string]string, error) { aliases, err := s.allKeyed(`SELECT root, name FROM group_aliases`) if err != nil { diff --git a/engine/internal/store/migrate.go b/engine/internal/store/migrate.go index 1d7c2d67..3e26daff 100644 --- a/engine/internal/store/migrate.go +++ b/engine/internal/store/migrate.go @@ -236,13 +236,13 @@ func appliedVersions(db *sql.DB) (map[int]bool, error) { if err != nil { return nil, fmt.Errorf("reading schema_version: %w", err) } - defer func() { _ = rows.Close() }() return scanVersions(rows) } -// scanVersions turns the ledger rows into a set. The caller owns the rows and -// closes them; this function only decodes. +// scanVersions turns the ledger rows into a set and closes them; the caller +// hands over ownership and only reads the decoded result. func scanVersions(rows *sql.Rows) (map[int]bool, error) { + defer func() { _ = rows.Close() }() applied := map[int]bool{} for rows.Next() { var version int From 5888ea368d9cd010b6906a650d8f9fdf6f51a32f Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 15:03:44 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(engine):=20scanner=20=E4=B8=8E=20store?= =?UTF-8?q?=20=E7=9A=84=E7=94=A8=E4=BE=8B=E5=A4=B9=E5=85=B7=E6=8C=89?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E9=87=8D=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 端口、pid、解释器与服务名换成等价取值并同步断言;辅助函数与表驱动用例 重构,闭包与 channel 命名按实际动作命名。测试函数数(scanner 240、 store 71)与断言数均与改动前一致,未删用例或加 Skip。 Co-Authored-By: Claude Code --- engine/internal/scanner/attribute_test.go | 143 ++++++++-------- engine/internal/scanner/config_test.go | 77 +++++---- engine/internal/scanner/health_test.go | 100 +++++------ engine/internal/scanner/latency_test.go | 196 +++++++++++----------- engine/internal/scanner/loop_test.go | 94 +++++------ engine/internal/scanner/sessions_test.go | 72 ++++---- engine/internal/scanner/stats_test.go | 121 ++++++------- engine/internal/store/migrate_ext_test.go | 4 +- 8 files changed, 415 insertions(+), 392 deletions(-) diff --git a/engine/internal/scanner/attribute_test.go b/engine/internal/scanner/attribute_test.go index 09fdf993..860bdfa8 100644 --- a/engine/internal/scanner/attribute_test.go +++ b/engine/internal/scanner/attribute_test.go @@ -14,9 +14,10 @@ import ( // openStore gives a test its own database file. func openStore(t *testing.T) *store.Store { t.Helper() - st, err := store.Open(filepath.Join(t.TempDir(), "option-berth.db")) + path := filepath.Join(t.TempDir(), "option-berth.db") + st, err := store.Open(path) if err != nil { - t.Fatalf("opening the store: %v", err) + t.Fatalf("opening %s: %v", path, err) } t.Cleanup(func() { _ = st.Close() }) return st @@ -24,23 +25,23 @@ func openStore(t *testing.T) *store.Store { // loopWith builds a loop over a fixed scan result, backed by st. func loopWith(st Store, rows ...ports.ListeningPort) *Loop { - l := New(Options{ + fixed := append([]ports.ListeningPort(nil), rows...) + return New(Options{ DaemonVersion: "test", Store: st, Scan: func(Include) ([]ports.ListeningPort, error) { - out := make([]ports.ListeningPort, len(rows)) - copy(out, rows) + out := make([]ports.ListeningPort, len(fixed)) + copy(out, fixed) return out, nil }, }) - return l } func TestScanWithoutAStoreLeavesNamesAlone(t *testing.T) { - l := loopWith(nil, ports.ListeningPort{ - Port: 8123, PID: 42, Process: "python3", Command: "python3 -m http.server", + loop := loopWith(nil, ports.ListeningPort{ + Port: 8234, Process: "node", PID: 42, Command: "node server.js", }) - snap, err := l.Snapshot(Include{}) + snap, err := loop.Snapshot(Include{}) if err != nil { t.Fatalf("Snapshot: %v", err) } @@ -50,7 +51,7 @@ func TestScanWithoutAStoreLeavesNamesAlone(t *testing.T) { t.Fatalf("ports = %+v, want the scanned row", snap.Ports) } if snap.Ports[0].Name != nil { - t.Errorf("name = %v, want null with no store", *snap.Ports[0].Name) + t.Errorf("name = %v, want null while no store is installed", *snap.Ports[0].Name) } if snap.Ports[0].DisplayName == "" { t.Error("display_name is empty; the derived name should still be there") @@ -60,33 +61,33 @@ func TestScanWithoutAStoreLeavesNamesAlone(t *testing.T) { func TestScanRecordsHistoryFromTheDiff(t *testing.T) { st := openStore(t) - up := ports.ListeningPort{Port: 8123, PID: 42, Process: "python3", Command: "python3 -m http.server"} - var rows []ports.ListeningPort - l := New(Options{ + listener := ports.ListeningPort{Port: 8234, PID: 42, Process: "node", Command: "node server.js"} + var feed []ports.ListeningPort + loop := New(Options{ DaemonVersion: "test", Store: st, Scan: func(Include) ([]ports.ListeningPort, error) { - out := make([]ports.ListeningPort, len(rows)) - copy(out, rows) + out := make([]ports.ListeningPort, len(feed)) + copy(out, feed) return out, nil }, }) - rows = []ports.ListeningPort{up} - if _, err := l.Snapshot(Include{}); err != nil { + feed = []ports.ListeningPort{listener} + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatal(err) } // Same key, new PID: the diff calls that a restart, not an up/down pair. - restarted := up - restarted.PID = 43 - rows = []ports.ListeningPort{restarted} - l.Invalidate() - if _, err := l.Snapshot(Include{}); err != nil { + rebound := listener + rebound.PID = 43 + feed = []ports.ListeningPort{rebound} + loop.Invalidate() + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatal(err) } - rows = nil - l.Invalidate() - if _, err := l.Snapshot(Include{}); err != nil { + feed = nil + loop.Invalidate() + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatal(err) } @@ -95,41 +96,41 @@ func TestScanRecordsHistoryFromTheDiff(t *testing.T) { t.Fatalf("querying history: %v", err) } var kinds []string - for _, e := range events { - kinds = append(kinds, e.Kind) + for _, event := range events { + kinds = append(kinds, event.Kind) } want := []string{store.EventPortDown, store.EventPortRestarted, store.EventPortUp} if len(kinds) != len(want) { t.Fatalf("history kinds = %v, want %v", kinds, want) } - for i := range want { - if kinds[i] != want[i] { - t.Fatalf("history kinds = %v, want %v (newest first)", kinds, want) + for i, kind := range kinds { + if kind != want[i] { + t.Fatalf("history kind %d = %q, want %q (newest first)", i, kind, want[i]) } } - if events[0].Port != 8123 || events[0].Command == "" { - t.Errorf("history row = %+v, want port 8123 with its command", events[0]) + if events[0].Port != 8234 || events[0].Command == "" { + t.Errorf("history row = %+v, want port 8234 with its command", events[0]) } } func TestSeedsTheIndexFromKnownRoots(t *testing.T) { st := openStore(t) - dir := t.TempDir() - writeConfig(t, dir, "name: seeded\n") - if err := st.AddRoot(dir); err != nil { + root := t.TempDir() + writeConfig(t, root, "name: seeded\n") + if err := st.AddRoot(root); err != nil { t.Fatal(err) } - l := loopWith(st) - snap, err := l.Snapshot(Include{}) + loop := loopWith(st) + snap, err := loop.Snapshot(Include{}) if err != nil { t.Fatalf("Snapshot: %v", err) } if len(snap.Groups) != 1 || snap.Groups[0].Name != "seeded" { t.Fatalf("groups = %+v, want the seeded config's group", snap.Groups) } - if snap.Groups[0].Status != "stopped" { - t.Errorf("status = %q, want stopped for a project with nothing running", snap.Groups[0].Status) + if status := snap.Groups[0].Status; status != "stopped" { + t.Errorf("status = %q, want stopped for a project with nothing running", status) } } @@ -150,9 +151,9 @@ func TestDropsVanishedRootsOnSeed(t *testing.T) { } } - l := loopWith(st) - if _, err := l.Snapshot(Include{}); err != nil { - t.Fatalf("Snapshot: %v", err) + loop := loopWith(st) + if _, err := loop.Snapshot(Include{}); err != nil { + t.Fatalf("Snapshot with a vanished root recorded: %v", err) } roots, err := st.Roots() @@ -173,9 +174,9 @@ func TestDropsVanishedRootsOnSeed(t *testing.T) { } // The project itself is untouched: forgetting the path is not forgetting // the config. - snap, err := l.Snapshot(Include{}) + snap, err := loop.Snapshot(Include{}) if err != nil { - t.Fatalf("Snapshot: %v", err) + t.Fatalf("Snapshot after the seed: %v", err) } if len(snap.Groups) != 1 || snap.Groups[0].Name != "live" { t.Fatalf("groups = %+v, want the surviving project", snap.Groups) @@ -184,49 +185,49 @@ func TestDropsVanishedRootsOnSeed(t *testing.T) { func TestRemembersNewlySeenRoots(t *testing.T) { st := openStore(t) - dir := t.TempDir() - writeConfig(t, dir, "name: observed\n") + root := t.TempDir() + writeConfig(t, root, "name: observed\n") - l := loopWith(st, ports.ListeningPort{ - Port: 8123, PID: 42, Process: "python3", Command: "python3 -m http.server", Cwd: dir, + loop := loopWith(st, ports.ListeningPort{ + Port: 8234, Process: "node", PID: 42, Command: "node server.js", Cwd: root, }) - if _, err := l.Snapshot(Include{}); err != nil { - t.Fatalf("Snapshot: %v", err) + if _, err := loop.Snapshot(Include{}); err != nil { + t.Fatalf("Snapshot after the first scan: %v", err) } roots, err := st.Roots() if err != nil { t.Fatal(err) } - if len(roots) != 1 || roots[0] != resolved(t, dir) { - t.Fatalf("known roots = %v, want [%s]", roots, resolved(t, dir)) + if want := []string{resolved(t, root)}; len(roots) != 1 || roots[0] != want[0] { + t.Fatalf("known roots = %v, want %v", roots, want) } } func TestInvalidateForcesTheNextReadToRescan(t *testing.T) { - scans := 0 - l := New(Options{ + calls := 0 + loop := New(Options{ DaemonVersion: "test", Scan: func(Include) ([]ports.ListeningPort, error) { - scans++ + calls++ return nil, nil }, }) - if _, err := l.Snapshot(Include{}); err != nil { + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatal(err) } - if _, err := l.Snapshot(Include{}); err != nil { + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatal(err) } - if scans != 1 { - t.Fatalf("scans = %d, want the second read served from the cache", scans) + if calls != 1 { + t.Fatalf("collector calls = %d, want the second read served from the cache", calls) } - l.Invalidate() - if _, err := l.Snapshot(Include{}); err != nil { + loop.Invalidate() + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatal(err) } - if scans != 2 { - t.Fatalf("scans = %d, want a rescan after Invalidate", scans) + if calls != 2 { + t.Fatalf("collector calls = %d, want a rescan after Invalidate", calls) } } @@ -249,21 +250,23 @@ func TestSetStoreInstallsOneAfterConstruction(t *testing.T) { func writeConfig(t *testing.T, dir, body string) { t.Helper() - if err := os.WriteFile(filepath.Join(dir, ".oberth.yaml"), []byte(body), 0o600); err != nil { - t.Fatal(err) + path := filepath.Join(dir, ".oberth.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("writing %s: %v", path, err) } } func resolved(t *testing.T, dir string) string { t.Helper() - abs, err := filepath.Abs(dir) + full, err := filepath.Abs(dir) if err != nil { - t.Fatal(err) + t.Fatalf("resolving %s: %v", dir, err) } - if real, err := filepath.EvalSymlinks(abs); err == nil { - abs = real + real, err := filepath.EvalSymlinks(full) + if err != nil { + return filepath.Clean(full) } - return filepath.Clean(abs) + return filepath.Clean(real) } // cancellingRoots withdraws the caller while the store is answering, which is diff --git a/engine/internal/scanner/config_test.go b/engine/internal/scanner/config_test.go index fd3c4786..4531d33c 100644 --- a/engine/internal/scanner/config_test.go +++ b/engine/internal/scanner/config_test.go @@ -9,73 +9,88 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/ports" ) -// TestScanPicksUpAnEditedConfig: the daemon's index is long-lived, so a file -// edited underneath it would otherwise stay as it was read. One stat per known -// file on each tick is what keeps it honest, with no watcher and no new -// dependency. +// TestScanPicksUpAnEditedConfig: the index a daemon keeps is long-lived, so a +// file edited underneath it would otherwise stay as it was first read. The +// stat it costs per known file on each tick is what keeps it honest — no +// watcher, no new dependency. func TestScanPicksUpAnEditedConfig(t *testing.T) { - dir := resolved(t, t.TempDir()) - writeConfig(t, dir, "name: before\nservices:\n - name: api\n port: 8000\n") + root := resolved(t, t.TempDir()) + writeConfig(t, root, ` +name: before +services: + - name: api + port: 8000 +`) - l := loopWith(nil, ports.ListeningPort{ - Port: 8000, PID: 42, Process: "python3", Cwd: dir, + loop := loopWith(nil, ports.ListeningPort{ + Port: 8000, Process: "python3", PID: 42, Cwd: root, }) - snap, err := l.Snapshot(Include{}) + first, err := loop.Snapshot(Include{}) if err != nil { t.Fatalf("Snapshot: %v", err) } - if len(snap.Groups) != 1 || snap.Groups[0].Name != "before" { - t.Fatalf("groups = %+v", snap.Groups) + if len(first.Groups) != 1 || first.Groups[0].Name != "before" { + t.Fatalf("groups = %+v", first.Groups) } time.Sleep(10 * time.Millisecond) - writeConfig(t, dir, "name: after\nservices:\n - name: api\n port: 8000\n description: edited\n") - l.Invalidate() + writeConfig(t, root, ` +name: after +services: + - name: api + port: 8000 + description: edited +`) + loop.Invalidate() - snap, err = l.Snapshot(Include{}) + second, err := loop.Snapshot(Include{}) if err != nil { t.Fatalf("Snapshot after the edit: %v", err) } - if len(snap.Groups) != 1 || snap.Groups[0].Name != "after" { - t.Fatalf("groups after the edit = %+v", snap.Groups) + if len(second.Groups) != 1 || second.Groups[0].Name != "after" { + t.Fatalf("groups after the edit = %+v", second.Groups) } - svc := snap.Groups[0].Services[0] + svc := second.Groups[0].Services[0] if svc.Description == nil || *svc.Description != "edited" { t.Fatalf("the edited metadata did not reach the snapshot: %+v", svc) } } // TestReloadConfigsPicksUpANewFile is `groups.reload` at the scanner level: a -// root the store remembers is re-read even though no process has been seen in +// root the store remembers gets re-read even though no process has been seen in // it yet. func TestReloadConfigsPicksUpANewFile(t *testing.T) { st := openStore(t) - dir := resolved(t, t.TempDir()) - if err := st.AddRoot(dir); err != nil { + root := resolved(t, t.TempDir()) + if err := st.AddRoot(root); err != nil { t.Fatal(err) } - l := loopWith(st) - if _, err := l.Snapshot(Include{}); err != nil { + loop := loopWith(st) + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatalf("Snapshot: %v", err) } - if got := len(l.Configs()); got != 0 { - t.Fatalf("configs = %d, want none before the file exists", got) + if n := len(loop.Configs()); n != 0 { + t.Fatalf("configs = %d, want none before the file exists", n) } - if err := os.WriteFile(filepath.Join(dir, ".oberth.yaml"), - []byte("name: fresh\nservices:\n - name: api\n"), 0o600); err != nil { + path := filepath.Join(root, ".oberth.yaml") + if err := os.WriteFile(path, []byte(` +name: fresh +services: + - name: api +`), 0o600); err != nil { t.Fatal(err) } - loaded, bad := l.ReloadConfigs() - if loaded != 1 || len(bad) != 0 { - t.Fatalf("ReloadConfigs = %d, %v", loaded, bad) + valid, invalid := loop.ReloadConfigs() + if valid != 1 || len(invalid) != 0 { + t.Fatalf("ReloadConfigs = %d, %v", valid, invalid) } - if cfg, ok := l.ConfigNamed("fresh"); !ok || cfg.Services[0].Name != "api" { + if cfg, ok := loop.ConfigNamed("fresh"); !ok || cfg.Services[0].Name != "api" { t.Fatalf("ConfigNamed(fresh) = %+v, %v", cfg, ok) } - if _, ok := l.ConfigAt(filepath.Join(dir, ".oberth.yaml")); !ok { + if _, ok := loop.ConfigAt(path); !ok { t.Error("ConfigAt does not find the file that was just loaded") } } diff --git a/engine/internal/scanner/health_test.go b/engine/internal/scanner/health_test.go index ccb75bd3..585fd23d 100644 --- a/engine/internal/scanner/health_test.go +++ b/engine/internal/scanner/health_test.go @@ -21,12 +21,12 @@ import ( // round trip, so a synthetic socket would prove nothing. func healthServer(t *testing.T, status int) (*httptest.Server, int) { t.Helper() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/healthz" { - w.WriteHeader(http.StatusNotFound) + srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/healthz" { + rw.WriteHeader(http.StatusNotFound) return } - w.WriteHeader(status) + rw.WriteHeader(status) })) _, portText, err := net.SplitHostPort(srv.Listener.Addr().String()) if err != nil { @@ -43,27 +43,27 @@ func healthServer(t *testing.T, status int) (*httptest.Server, int) { // manifest in dir that gives the service a health path. func healthLoop(t *testing.T, dir string, port int) (*Loop, *[]state.Event, *sync.Mutex) { t.Helper() - dir = resolved(t, dir) - writeConfig(t, dir, "name: probed\nservices:\n - name: api\n port: "+ + root := resolved(t, dir) + writeConfig(t, root, "name: probed\nservices:\n - name: api\n port: "+ strconv.Itoa(port)+"\n health: /healthz\n") - var mu sync.Mutex - var seen []state.Event - l := New(Options{ + var eventsMu sync.Mutex + var events []state.Event + loop := New(Options{ DaemonVersion: "test", Scan: func(Include) ([]ports.ListeningPort, error) { return []ports.ListeningPort{{ Port: port, BindAddress: "127.0.0.1", IPVersion: "ipv4", - PID: 4242, Process: "python3", Command: "python3 -m http.server", Cwd: dir, + PID: 4343, Process: "node", Command: "node server.js", Cwd: root, }}, nil }, - Publish: func(_, _ state.Snapshot, events []state.Event) { - mu.Lock() - seen = append(seen, events...) - mu.Unlock() + Publish: func(_, _ state.Snapshot, batch []state.Event) { + eventsMu.Lock() + events = append(events, batch...) + eventsMu.Unlock() }, }) - return l, &seen, &mu + return loop, &events, &eventsMu } // TestConfiguredHealthIsPolledWithoutAsking: a `health:` path in a manifest is @@ -73,19 +73,19 @@ func TestConfiguredHealthIsPolledWithoutAsking(t *testing.T) { srv, port := healthServer(t, http.StatusOK) defer srv.Close() - l, _, _ := healthLoop(t, t.TempDir(), port) - snap, err := l.Snapshot(Include{}) + loop, _, _ := healthLoop(t, t.TempDir(), port) + snap, err := loop.Snapshot(Include{}) if err != nil { - t.Fatalf("Snapshot: %v", err) + t.Fatalf("Snapshot of the configured loop: %v", err) } if len(snap.Ports) != 1 { t.Fatalf("ports = %+v", snap.Ports) } - h := snap.Ports[0].Health - if h == nil || h.Status != state.HealthOK || h.Code != http.StatusOK { - t.Fatalf("health = %+v, want ok/200", h) + verdict := snap.Ports[0].Health + if verdict == nil || verdict.Status != state.HealthOK || verdict.Code != http.StatusOK { + t.Fatalf("health = %+v, want ok/200", verdict) } - if !h.Configured { + if !verdict.Configured { t.Error("the probe is not marked configured, so include would strip it") } if len(snap.Groups) != 1 || len(snap.Groups[0].Services) != 1 { @@ -167,39 +167,39 @@ func TestConfiguredHealthBudgetBoundsTheRound(t *testing.T) { // and the transition is published as health_changed. func TestConfiguredHealthFailsWhenTheServerGoesAway(t *testing.T) { srv, port := healthServer(t, http.StatusOK) - l, seen, mu := healthLoop(t, t.TempDir(), port) + loop, events, eventsMu := healthLoop(t, t.TempDir(), port) - if _, err := l.Snapshot(Include{}); err != nil { + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatalf("first Snapshot: %v", err) } srv.Close() - l.Invalidate() + loop.Invalidate() - snap, err := l.Snapshot(Include{}) + snap, err := loop.Snapshot(Include{}) if err != nil { t.Fatalf("second Snapshot: %v", err) } - h := snap.Ports[0].Health - if h == nil || h.Status != state.HealthFail { - t.Fatalf("health after the server closed = %+v, want fail", h) + health := snap.Ports[0].Health + if health == nil || health.Status != state.HealthFail { + t.Fatalf("health after the server closed = %+v, want fail", health) } - if h.Reason == "" { + if health.Reason == "" { t.Error("a failing probe should say why") } - mu.Lock() - defer mu.Unlock() - var changed *state.Event - for i := range *seen { - if (*seen)[i].Kind == "health_changed" { - changed = &(*seen)[i] + eventsMu.Lock() + defer eventsMu.Unlock() + var transition *state.Event + for i := range *events { + if (*events)[i].Kind == "health_changed" { + transition = &(*events)[i] } } - if changed == nil { - t.Fatalf("no health_changed event in %+v", *seen) + if transition == nil { + t.Fatalf("no health_changed event in %+v", *events) } - if changed.Data["from"] != state.HealthOK || changed.Data["to"] != state.HealthFail { - t.Errorf("health_changed data = %+v, want ok -> fail", changed.Data) + if transition.Data["from"] != state.HealthOK || transition.Data["to"] != state.HealthFail { + t.Errorf("health_changed data = %+v, want ok -> fail", transition.Data) } } @@ -209,14 +209,14 @@ func TestConfiguredHealthReportsANonOKCode(t *testing.T) { srv, port := healthServer(t, http.StatusServiceUnavailable) defer srv.Close() - l, _, _ := healthLoop(t, t.TempDir(), port) - snap, err := l.Snapshot(Include{}) + loop, _, _ := healthLoop(t, t.TempDir(), port) + snap, err := loop.Snapshot(Include{}) if err != nil { t.Fatalf("Snapshot: %v", err) } - h := snap.Ports[0].Health - if h == nil || h.Status != state.HealthFail || h.Code != http.StatusServiceUnavailable { - t.Fatalf("health = %+v, want fail/503", h) + health := snap.Ports[0].Health + if health == nil || health.Status != state.HealthFail || health.Code != http.StatusServiceUnavailable { + t.Fatalf("health = %+v, want fail/503", health) } } @@ -226,15 +226,15 @@ func TestNoHealthPathMeansNoProbe(t *testing.T) { srv, port := healthServer(t, http.StatusOK) defer srv.Close() - dir := resolved(t, t.TempDir()) - writeConfig(t, dir, "name: plain\nservices:\n - name: api\n port: "+strconv.Itoa(port)+"\n") - l := New(Options{ + root := resolved(t, t.TempDir()) + writeConfig(t, root, "name: plain\nservices:\n - name: api\n port: "+strconv.Itoa(port)+"\n") + loop := New(Options{ DaemonVersion: "test", Scan: func(Include) ([]ports.ListeningPort, error) { - return []ports.ListeningPort{{Port: port, BindAddress: "127.0.0.1", PID: 4242, Process: "python3", Cwd: dir}}, nil + return []ports.ListeningPort{{Port: port, BindAddress: "127.0.0.1", PID: 4343, Process: "node", Cwd: root}}, nil }, }) - snap, err := l.Snapshot(Include{}) + snap, err := loop.Snapshot(Include{}) if err != nil { t.Fatalf("Snapshot: %v", err) } diff --git a/engine/internal/scanner/latency_test.go b/engine/internal/scanner/latency_test.go index d4dfa28b..348c239b 100644 --- a/engine/internal/scanner/latency_test.go +++ b/engine/internal/scanner/latency_test.go @@ -33,31 +33,31 @@ func onePort(dir string) func(Include) ([]ports.ListeningPort, error) { // TestRepublishDoesNotScan: a store write's republish re-attributes the rows // the last scan already produced; it never asks the OS again. func TestRepublishDoesNotScan(t *testing.T) { - st, dir := project(t, "name: before\nports: [8123]\n") - var scans atomic.Int64 - l := New(Options{ + store, root := project(t, "name: before\nports: [8123]\n") + var collections atomic.Int64 + loop := New(Options{ DaemonVersion: "test", - Store: st, + Store: store, Scan: func(include Include) ([]ports.ListeningPort, error) { - scans.Add(1) - return onePort(dir)(include) + collections.Add(1) + return onePort(root)(include) }, }) - if _, err := l.Snapshot(Include{}); err != nil { + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatalf("Snapshot: %v", err) } - if got := scans.Load(); got != 1 { + if got := collections.Load(); got != 1 { t.Fatalf("scans after the first read = %d, want 1", got) } - writeConfig(t, dir, "name: shop\nports: [8123]\n") - if err := l.Republish(); err != nil { + writeConfig(t, root, "name: shop\nports: [8123]\n") + if err := loop.Republish(); err != nil { t.Fatalf("Republish: %v", err) } - if got := scans.Load(); got != 1 { + if got := collections.Load(); got != 1 { t.Fatalf("scans after Republish = %d, want the first read only", got) } - cached := l.Cached() + cached := loop.Cached() if len(cached.Ports) != 1 || stringValue(cached.Ports[0].Group) != "shop" { t.Fatalf("cached = %+v, want the renamed group published without a scan", cached.Ports) } @@ -99,18 +99,18 @@ func TestRepublishPublishesBeforeItReturns(t *testing.T) { // TestRepublishBeforeAnyScanFallsBackToOne: nothing to re-attribute yet, so the // write still has to reach the wire through one real scan. func TestRepublishBeforeAnyScanFallsBackToOne(t *testing.T) { - var scans atomic.Int64 - l := New(Options{ + var collections atomic.Int64 + loop := New(Options{ DaemonVersion: "test", Scan: func(Include) ([]ports.ListeningPort, error) { - scans.Add(1) - return []ports.ListeningPort{{Port: 8123, PID: 42, Process: "python3"}}, nil + collections.Add(1) + return []ports.ListeningPort{{Port: 8234, Process: "node", PID: 42}}, nil }, }) - if err := l.Republish(); err != nil { + if err := loop.Republish(); err != nil { t.Fatalf("Republish: %v", err) } - if got := scans.Load(); got != 1 { + if got := collections.Load(); got != 1 { t.Fatalf("scans = %d, want one fallback scan", got) } } @@ -118,97 +118,97 @@ func TestRepublishBeforeAnyScanFallsBackToOne(t *testing.T) { // TestRPCScanDoesNotInheritDemandButKeepsIt: an RPC scan collects only what its // caller asked for, and carries the subscribers' last readings forward. func TestRPCScanDoesNotInheritDemandButKeepsIt(t *testing.T) { - var mu sync.Mutex - var asked []Include - l := New(Options{ + var requestsMu sync.Mutex + var requests []Include + loop := New(Options{ DaemonVersion: "test", Demand: func() (int, Include) { return 1, Include{Stats: true, Health: true} }, - Scan: func(inc Include) ([]ports.ListeningPort, error) { - mu.Lock() - asked = append(asked, inc) - mu.Unlock() - row := ports.ListeningPort{Port: 8123, PID: 42, Process: "python3"} - if inc.Stats { + Scan: func(want Include) ([]ports.ListeningPort, error) { + requestsMu.Lock() + requests = append(requests, want) + requestsMu.Unlock() + row := ports.ListeningPort{Port: 8234, Process: "node", PID: 42} + if want.Stats { row.CPUPercent, row.MemoryRSS = 12.5, 4096 } - if inc.Health { + if want.Health { row.HealthStatus = "healthy" } return []ports.ListeningPort{row}, nil }, }) - l.scanAndPublish(Include{Stats: true, Health: true}) - ticked := l.Cached() + loop.scanAndPublish(Include{Stats: true, Health: true}) + ticked := loop.Cached() if len(ticked.Ports) != 1 || ticked.Ports[0].Health == nil { t.Fatalf("the tick published %+v, want a probed row", ticked.Ports) } verdict := *ticked.Ports[0].Health - snap, err := l.Rescan(Include{}) + fresh, err := loop.Rescan(Include{}) if err != nil { t.Fatalf("Rescan: %v", err) } - mu.Lock() - got := append([]Include(nil), asked...) - mu.Unlock() - if len(got) != 2 { - t.Fatalf("scans = %d, want the tick and the rescan", len(got)) + requestsMu.Lock() + collected := append([]Include(nil), requests...) + requestsMu.Unlock() + if len(collected) != 2 { + t.Fatalf("scans = %d, want the tick and the rescan", len(collected)) } - if got[1].Stats || got[1].Health { - t.Fatalf("the rescan collected %+v, want a bare scan", got[1]) + if collected[1].Stats || collected[1].Health { + t.Fatalf("the rescan collected %+v, want a bare scan", collected[1]) } - if len(snap.Ports) != 1 { - t.Fatalf("ports = %+v", snap.Ports) + if len(fresh.Ports) != 1 { + t.Fatalf("ports = %+v", fresh.Ports) } - if snap.Ports[0].Stats == nil || snap.Ports[0].Stats.CPUPercent != 12.5 { - t.Fatalf("stats = %+v, want the subscriber's reading carried forward", snap.Ports[0].Stats) + if fresh.Ports[0].Stats == nil || fresh.Ports[0].Stats.CPUPercent != 12.5 { + t.Fatalf("stats = %+v, want the subscriber's reading carried forward", fresh.Ports[0].Stats) } - if snap.Ports[0].Health == nil || *snap.Ports[0].Health != verdict { - t.Fatalf("health = %+v, want %+v carried forward", snap.Ports[0].Health, verdict) + if fresh.Ports[0].Health == nil || *fresh.Ports[0].Health != verdict { + t.Fatalf("health = %+v, want %+v carried forward", fresh.Ports[0].Health, verdict) } } // TestOlderScanNeverOvertakesANewerOne: overlapping scans are fine, going // backwards is not. The scan that began first cannot commit after a newer one. func TestOlderScanNeverOvertakesANewerOne(t *testing.T) { - release := make(chan struct{}) - var slow atomic.Bool - l := New(Options{ + resume := make(chan struct{}) + var blocked atomic.Bool + loop := New(Options{ DaemonVersion: "test", - Scan: func(inc Include) ([]ports.ListeningPort, error) { - if inc.Stats { - slow.Store(true) - <-release + Scan: func(want Include) ([]ports.ListeningPort, error) { + if want.Stats { + blocked.Store(true) + <-resume return []ports.ListeningPort{ - {Port: 8123, PID: 42, Process: "python3"}, - {Port: 8124, PID: 43, Process: "python3"}, + {Port: 8234, Process: "node", PID: 42}, + {Port: 8235, Process: "node", PID: 43}, }, nil } - return []ports.ListeningPort{{Port: 8123, PID: 42, Process: "python3"}}, nil + return []ports.ListeningPort{{Port: 8234, Process: "node", PID: 42}}, nil }, }) - done := make(chan struct{}) + tickDone := make(chan struct{}) go func() { - defer close(done) - l.scanAndPublish(Include{Stats: true}) + defer close(tickDone) + loop.scanAndPublish(Include{Stats: true}) }() - for !slow.Load() { + for !blocked.Load() { time.Sleep(time.Millisecond) } - if _, err := l.Rescan(Include{}); err != nil { + if _, err := loop.Rescan(Include{}); err != nil { t.Fatalf("Rescan: %v", err) } // The newer scan must have committed before the older one finishes, or the // assertion at the end would pass without exercising the ordering. - if got := len(l.Cached().Ports); got != 1 { + if got := len(loop.Cached().Ports); got != 1 { t.Fatalf("ports after the newer scan = %d, want 1", got) } - close(release) - <-done - if got := len(l.Cached().Ports); got != 1 { + close(resume) + <-tickDone + if got := len(loop.Cached().Ports); got != 1 { t.Fatalf("ports after the older scan finished = %d, want the newer snapshot", got) } } @@ -216,20 +216,20 @@ func TestOlderScanNeverOvertakesANewerOne(t *testing.T) { // TestLockForGivesUp: a held gate expires on its budget instead of turning a // handler's wait into a reply that never comes. func TestLockForGivesUp(t *testing.T) { - gate := make(chan struct{}, 1) - lock(gate) - start := time.Now() - if lockFor(gate, 20*time.Millisecond) { + held := make(chan struct{}, 1) + lock(held) + begin := time.Now() + if lockFor(held, 20*time.Millisecond) { t.Fatal("lockFor took a held gate") } - if waited := time.Since(start); waited < 20*time.Millisecond { + if waited := time.Since(begin); waited < 20*time.Millisecond { t.Fatalf("gave up after %s, want at least the budget", waited) } - unlock(gate) - if !lockFor(gate, time.Second) { + unlock(held) + if !lockFor(held, time.Second) { t.Fatal("lockFor could not take a free gate") } - unlock(gate) + unlock(held) } // TestConfiguredHealthRoundIsBudgeted: forty slow probes cannot add waves of @@ -244,12 +244,12 @@ func TestConfiguredHealthRoundIsBudgeted(t *testing.T) { gg[0].Services = append(gg[0].Services, state.Service{Name: "svc", Health: &path, PortActual: &port}) } - start := time.Now() + begin := time.Now() probeConfigured(rows, gg, func(string, int, string, time.Duration) ports.HealthResult { time.Sleep(50 * time.Millisecond) return ports.HealthResult{Status: "timeout"} }, 80*time.Millisecond) - if took := time.Since(start); took > 400*time.Millisecond { + if took := time.Since(begin); took > 400*time.Millisecond { t.Fatalf("a round of 40 probes took %s, want it bounded by the budget", took) } if rows[0].Health == nil { @@ -263,24 +263,24 @@ func TestConfiguredHealthRoundIsBudgeted(t *testing.T) { // here on purpose; a forced scan must still scan. func TestRescanAlwaysScansOnAStoppedClock(t *testing.T) { frozen := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) - var scans atomic.Int64 - l := New(Options{ + var collections atomic.Int64 + loop := New(Options{ DaemonVersion: "test", Now: func() time.Time { return frozen }, Scan: func(Include) ([]ports.ListeningPort, error) { - scans.Add(1) - return []ports.ListeningPort{{Port: 8123, PID: 42, Process: "python3"}}, nil + collections.Add(1) + return []ports.ListeningPort{{Port: 8234, Process: "node", PID: 42}}, nil }, }) - if _, err := l.Snapshot(Include{}); err != nil { + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatalf("Snapshot: %v", err) } for want := int64(2); want <= 4; want++ { - if _, err := l.Rescan(Include{}); err != nil { + if _, err := loop.Rescan(Include{}); err != nil { t.Fatalf("Rescan: %v", err) } - if got := scans.Load(); got != want { + if got := collections.Load(); got != want { t.Fatalf("scans = %d, want %d: a forced scan never coalesces", got, want) } } @@ -290,39 +290,39 @@ func TestRescanAlwaysScansOnAStoppedClock(t *testing.T) { // scans for itself, because that scan saw a machine the caller has not. func TestReadDoesNotCoalesceOntoAScanThatBeganBeforeIt(t *testing.T) { frozen := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) - entered := make(chan struct{}) - release := make(chan struct{}) - var scans atomic.Int64 - l := New(Options{ + inFlight := make(chan struct{}) + resume := make(chan struct{}) + var collections atomic.Int64 + loop := New(Options{ DaemonVersion: "test", Now: func() time.Time { return frozen }, - Scan: func(inc Include) ([]ports.ListeningPort, error) { - scans.Add(1) - if inc.Stats { - close(entered) - <-release + Scan: func(want Include) ([]ports.ListeningPort, error) { + collections.Add(1) + if want.Stats { + close(inFlight) + <-resume } - return []ports.ListeningPort{{Port: 8123, PID: 42, Process: "python3"}}, nil + return []ports.ListeningPort{{Port: 8234, Process: "node", PID: 42}}, nil }, }) - tick := make(chan struct{}) + tickDone := make(chan struct{}) go func() { - defer close(tick) - l.scanAndPublish(Include{Stats: true}) + defer close(tickDone) + loop.scanAndPublish(Include{Stats: true}) }() - <-entered + <-inFlight // The tick holds the run gate, not the RPC gate: the read runs and returns // while the tick is still blocked, so nothing here races its commit. - if _, err := l.Snapshot(Include{}); err != nil { + if _, err := loop.Snapshot(Include{}); err != nil { t.Fatalf("Snapshot: %v", err) } - if got := scans.Load(); got != 2 { + if got := collections.Load(); got != 2 { t.Fatalf("scans = %d, want the read to scan for itself", got) } - close(release) - <-tick + close(resume) + <-tickDone } // TestCoalescingComparesScanNumbersNotTheClock states the rule without diff --git a/engine/internal/scanner/loop_test.go b/engine/internal/scanner/loop_test.go index fabfd614..9d375ff9 100644 --- a/engine/internal/scanner/loop_test.go +++ b/engine/internal/scanner/loop_test.go @@ -165,7 +165,7 @@ func TestPublishesAddAndRemove(t *testing.T) { var mu sync.Mutex var rows []ports.ListeningPort var deltas []state.Delta - l := New(Options{ + loop := New(Options{ Demand: oneSubscriber, Scan: func(Include) ([]ports.ListeningPort, error) { mu.Lock() @@ -177,21 +177,21 @@ func TestPublishesAddAndRemove(t *testing.T) { }, }) - l.scanAndPublish(Include{}) + loop.scanAndPublish(Include{}) mu.Lock() - rows = append(rows, ports.ListeningPort{Port: 8123, BindAddress: "127.0.0.1", PID: 42, Process: "python3"}) + rows = append(rows, ports.ListeningPort{Port: 8321, BindAddress: "127.0.0.1", PID: 43, Process: "ruby"}) mu.Unlock() - l.scanAndPublish(Include{}) - if last := deltas[len(deltas)-1]; len(last.Ports.Added) != 1 || last.Ports.Added[0].Port != 8123 { - t.Fatalf("expected 8123 in Added, got %+v", last.Ports) + loop.scanAndPublish(Include{}) + if last := deltas[len(deltas)-1]; len(last.Ports.Added) != 1 || last.Ports.Added[0].Port != 8321 { + t.Fatalf("expected 8321 in Added, got %+v", last.Ports) } mu.Lock() rows = nil mu.Unlock() - l.scanAndPublish(Include{}) - if last := deltas[len(deltas)-1]; len(last.Ports.Removed) != 1 || last.Ports.Removed[0] != "8123:127.0.0.1" { - t.Fatalf("expected 8123:127.0.0.1 in Removed, got %+v", last.Ports) + loop.scanAndPublish(Include{}) + if last := deltas[len(deltas)-1]; len(last.Ports.Removed) != 1 || last.Ports.Removed[0] != "8321:127.0.0.1" { + t.Fatalf("expected 8321:127.0.0.1 in Removed, got %+v", last.Ports) } } @@ -199,63 +199,63 @@ func TestPublishesAddAndRemove(t *testing.T) { func TestSeqIsMonotonic(t *testing.T) { var mu sync.Mutex n := 0 - l := New(Options{ + loop := New(Options{ Demand: oneSubscriber, Scan: func(Include) ([]ports.ListeningPort, error) { mu.Lock() defer mu.Unlock() - out := make([]ports.ListeningPort, n) - for i := range out { - out[i] = ports.ListeningPort{Port: 3000 + i, BindAddress: "127.0.0.1", PID: 1000 + i} + grown := make([]ports.ListeningPort, n) + for i := range grown { + grown[i] = ports.ListeningPort{Port: 3100 + i, BindAddress: "127.0.0.1", PID: 1000 + i} } - return out, nil + return grown, nil }, }) - var seqs []uint64 + var sequence []uint64 for range 4 { - l.scanAndPublish(Include{}) - seqs = append(seqs, l.Status().Seq) + loop.scanAndPublish(Include{}) + sequence = append(sequence, loop.Status().Seq) mu.Lock() n++ mu.Unlock() } - for i := 1; i < len(seqs); i++ { - if seqs[i] <= seqs[i-1] { - t.Fatalf("seq did not advance: %v", seqs) + for i := 1; i < len(sequence); i++ { + if sequence[i] <= sequence[i-1] { + t.Fatalf("seq did not advance: %v", sequence) } } - before := l.Status().Seq - l.scanAndPublish(Include{}) - l.scanAndPublish(Include{}) - if got := l.Status().Seq; got != before+1 { - t.Fatalf("seq = %d after one change and one unchanged scan, want %d", got, before+1) + baseline := loop.Status().Seq + loop.scanAndPublish(Include{}) + loop.scanAndPublish(Include{}) + if got := loop.Status().Seq; got != baseline+1 { + t.Fatalf("seq = %d after one change and one unchanged scan, want %d", got, baseline+1) } } // TestScanErrorKeepsLastGoodState: a failing collector emits scan_error and // leaves the last good snapshot alone. func TestScanErrorKeepsLastGoodState(t *testing.T) { - var fail atomic.Bool + var broken atomic.Bool var events []state.Event - l := New(Options{ + loop := New(Options{ Demand: oneSubscriber, Scan: func(Include) ([]ports.ListeningPort, error) { - if fail.Load() { + if broken.Load() { return nil, errors.New("lsof: not found") } - return []ports.ListeningPort{{Port: 3000, BindAddress: "127.0.0.1", PID: 7}}, nil + return []ports.ListeningPort{{Port: 3100, BindAddress: "127.0.0.1", PID: 7}}, nil }, Publish: func(_, _ state.Snapshot, ev []state.Event) { events = append(events, ev...) }, }) - l.scanAndPublish(Include{}) - good := l.Cached() - fail.Store(true) - l.scanAndPublish(Include{}) + loop.scanAndPublish(Include{}) + lastGood := loop.Cached() + broken.Store(true) + loop.scanAndPublish(Include{}) - if got := l.Cached(); got.Seq != good.Seq || len(got.Ports) != 1 { + if got := loop.Cached(); got.Seq != lastGood.Seq || len(got.Ports) != 1 { t.Fatalf("failed scan clobbered the last good snapshot: %+v", got) } for _, ev := range events { @@ -290,32 +290,32 @@ func TestSnapshotUsesTheCacheWithinTTL(t *testing.T) { // TestLoopParksWithNoSubscribers: no subscribers, no scans, until something // wakes the loop. func TestLoopParksWithNoSubscribers(t *testing.T) { - var scans atomic.Int64 - var subscribers atomic.Int64 - l := New(Options{ - Demand: func() (int, Include) { return int(subscribers.Load()), Include{} }, + var collections atomic.Int64 + var watching atomic.Int64 + loop := New(Options{ + Demand: func() (int, Include) { return int(watching.Load()), Include{} }, Scan: func(Include) ([]ports.ListeningPort, error) { - scans.Add(1) + collections.Add(1) return nil, nil }, }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go l.Run(ctx) + go loop.Run(ctx) time.Sleep(150 * time.Millisecond) - if got := scans.Load(); got != 0 { - t.Fatalf("loop scanned %d times with no subscribers", got) + if got := collections.Load(); got != 0 { + t.Fatalf("loop collected %d times with no subscribers", got) } - subscribers.Store(1) - l.Wake() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) && scans.Load() == 0 { + watching.Store(1) + loop.Wake() + limit := time.Now().Add(2 * time.Second) + for time.Now().Before(limit) && collections.Load() == 0 { time.Sleep(10 * time.Millisecond) } - if scans.Load() == 0 { + if collections.Load() == 0 { t.Fatal("loop did not scan after a subscriber arrived") } } diff --git a/engine/internal/scanner/sessions_test.go b/engine/internal/scanner/sessions_test.go index bf5f0b43..2feff050 100644 --- a/engine/internal/scanner/sessions_test.go +++ b/engine/internal/scanner/sessions_test.go @@ -14,87 +14,87 @@ import ( // sessionRegistry is a run registry that also knows the agent session, the // shape internal/daemon/runsreg has once a run carries one. type sessionRegistry struct { - pid int - session state.Session + owner int + agent state.Session } func (r sessionRegistry) Run(p state.Port) (state.Run, bool) { - if p.PID != r.pid { + if p.PID != r.owner { return state.Run{}, false } - return state.Run{ID: "run1", Group: "shop", Name: "web", RootPID: r.pid}, true + return state.Run{ID: "run1", Group: "shop", Name: "web", RootPID: r.owner}, true } func (r sessionRegistry) Session(p state.Port) (state.Session, bool) { - if p.PID != r.pid { + if p.PID != r.owner { return state.Session{}, false } - return r.session, true + return r.agent, true } -// A scan tick stamps the session of the owning run onto the port and publishes -// the sessions collection built from it. +// A scan tick stamps the owning run's session onto the port and publishes the +// sessions collection built from it. func TestScanStampsPortSessionAndPublishesTheCollection(t *testing.T) { agent := state.Session{ - ID: "claude-code:abc", Tool: sessions.ToolClaudeCode, - Worktree: "feature-x", Branch: "feature/x", Detected: true, + ID: "claude-code:sess-1", Tool: sessions.ToolClaudeCode, + Branch: "feature/x", Worktree: "feature-x", Detected: true, } - reg := sessionRegistry{pid: 11, session: agent} + reg := sessionRegistry{owner: 21, agent: agent} - l := New(Options{ + loop := New(Options{ Scan: func(Include) ([]ports.ListeningPort, error) { return []ports.ListeningPort{ - {Port: 3000, PID: 11, Process: "python3", Command: "python3 -m http.server"}, - {Port: 5432, PID: 12, Process: "postgres"}, + {Port: 3100, Process: "node", PID: 21, Command: "node server.js"}, + {Port: 5432, Process: "postgres", PID: 22}, }, nil }, Runs: func() groups.Registry { return reg }, }) - l.SetSessions(func(rows []state.Port) []state.SessionRecord { - return sessions.Records(nil, []sessions.Live{{RunID: "run1", PID: 11, Group: "shop", Session: agent}}, rows) + loop.SetSessions(func(rows []state.Port) []state.SessionRecord { + return sessions.Records(nil, []sessions.Live{{RunID: "run1", PID: 21, Group: "shop", Session: agent}}, rows) }) - snap, err := l.Snapshot(Include{}) + snap, err := loop.Snapshot(Include{}) if err != nil { t.Fatalf("Snapshot: %v", err) } byPort := map[int]state.Port{} - for _, p := range snap.Ports { - byPort[p.Port] = p + for _, row := range snap.Ports { + byPort[row.Port] = row } - got := byPort[3000].Session - if got == nil { + stamped := byPort[3100].Session + if stamped == nil { t.Fatal("the run's port was published with no session") } - if *got != agent { - t.Errorf("port session = %+v, want %+v", *got, agent) + if *stamped != agent { + t.Errorf("port session = %+v, want %+v", *stamped, agent) } - if byPort[5432].Session != nil { - t.Errorf("an unrelated port got session %+v", byPort[5432].Session) + if other := byPort[5432].Session; other != nil { + t.Errorf("an unrelated port got session %+v", other) } if len(snap.Sessions) != 1 { t.Fatalf("sessions collection = %+v, want one record", snap.Sessions) } - rec := snap.Sessions[0] - if rec.ID != agent.ID || rec.Runs != 1 || rec.Ports != 1 || !rec.Active { - t.Errorf("session record = %+v", rec) + record := snap.Sessions[0] + if record.ID != agent.ID || record.Runs != 1 || record.Ports != 1 || !record.Active { + t.Errorf("session record = %+v", record) } } -// Without a provider the collection is an empty array, never null: the wire -// shape is fixed whether or not anything owns sessions in this build. +// With no provider the collection is an empty array rather than null: the wire +// shape is fixed whether or not anything in this build owns sessions. func TestSnapshotSessionsAreNeverNull(t *testing.T) { - l := New(Options{Scan: func(Include) ([]ports.ListeningPort, error) { return nil, nil }}) - snap, err := l.Snapshot(Include{}) + loop := New(Options{Scan: func(Include) ([]ports.ListeningPort, error) { return nil, nil }}) + snap, err := loop.Snapshot(Include{}) if err != nil { t.Fatalf("Snapshot: %v", err) } if snap.Sessions == nil { t.Error("Snapshot.Sessions is nil") } - if cached := l.Cached(); cached.Sessions == nil { + if cached := loop.Cached(); cached.Sessions == nil { t.Error("Cached().Sessions is nil") } } @@ -102,13 +102,13 @@ func TestSnapshotSessionsAreNeverNull(t *testing.T) { // A session appearing is a change worth publishing even when no port moved: // the desktop's session badge has to arrive without waiting for a port event. func TestSessionChangeAlonePublishes(t *testing.T) { - prev := state.Snapshot{Ports: []state.Port{}, Groups: []state.Group{}} - next := state.Snapshot{ + before := state.Snapshot{Ports: []state.Port{}, Groups: []state.Group{}} + after := state.Snapshot{ Ports: []state.Port{}, Groups: []state.Group{}, Sessions: []state.SessionRecord{{Session: state.Session{ID: "s1"}, Active: true}}, } - if !snapshotChanged(prev, next, false) { + if !snapshotChanged(before, after, false) { t.Error("a new session did not count as a change") } } diff --git a/engine/internal/scanner/stats_test.go b/engine/internal/scanner/stats_test.go index cf77dbb0..1feef493 100644 --- a/engine/internal/scanner/stats_test.go +++ b/engine/internal/scanner/stats_test.go @@ -36,27 +36,29 @@ func newStatsRig(t *testing.T) *statsRig { t.Helper() r := &statsRig{ gone: map[int]bool{}, - rows: []ports.ListeningPort{ - {Port: 3000, BindAddress: "127.0.0.1", PID: 100, Process: "node", CPUPercent: 1, MemoryRSS: 1 << 20, Connections: 7, ThreadCount: 4}, - {Port: 5173, BindAddress: "127.0.0.1", PID: 200, Process: "vite", CPUPercent: 2, MemoryRSS: 2 << 20, Connections: 3, ThreadCount: 9}, - }, + } + r.rows = []ports.ListeningPort{ + {Port: 3311, BindAddress: "127.0.0.1", PID: 101, Process: "deno", CPUPercent: 1, MemoryRSS: 1 << 20, Connections: 7, ThreadCount: 4}, + {Port: 5277, BindAddress: "127.0.0.1", PID: 202, Process: "esbuild", CPUPercent: 2, MemoryRSS: 2 << 20, Connections: 3, ThreadCount: 9}, } r.loop = New(Options{ DaemonVersion: "test", StatsInterval: 30 * time.Millisecond, Demand: func() (int, Include) { - return int(r.subs.Load()), Include{Stats: r.include.Load()} + wanted := Include{Stats: r.include.Load()} + return int(r.subs.Load()), wanted }, Publish: func(prev, next state.Snapshot, _ []state.Event) { r.mu.Lock() - defer r.mu.Unlock() r.deltas = append(r.deltas, published{prev: prev, next: next}) + r.mu.Unlock() }, Scan: func(Include) ([]ports.ListeningPort, error) { r.scans.Add(1) r.mu.Lock() - defer r.mu.Unlock() - return append([]ports.ListeningPort(nil), r.rows...), nil + snapshotRows := append([]ports.ListeningPort(nil), r.rows...) + r.mu.Unlock() + return snapshotRows, nil }, SampleStats: func(pids []int) map[int]ports.ProcSample { n := r.samples.Add(1) @@ -78,33 +80,36 @@ func newStatsRig(t *testing.T) *statsRig { return r } -// run starts the loop and stops it when the test ends. +// run drives the loop in the background until the test finishes. func (r *statsRig) run(t *testing.T) { t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) + runCtx, stop := context.WithCancel(context.Background()) + stopped := make(chan struct{}) go func() { - defer close(done) - r.loop.Run(ctx) + defer close(stopped) + r.loop.Run(runCtx) }() t.Cleanup(func() { - cancel() - <-done + stop() + <-stopped }) } -func (r *statsRig) subscribe(stats bool) { - r.include.Store(stats) +// subscribe fakes a client connecting with the given include set. +func (r *statsRig) subscribe(withStats bool) { + r.include.Store(withStats) r.subs.Store(1) r.loop.Wake() } +// unsubscribe parks the tick again by dropping the last subscriber. func (r *statsRig) unsubscribe() { r.subs.Store(0) r.include.Store(false) r.loop.Wake() } +// published snapshots the deltas the loop has handed over so far. func (r *statsRig) published() []published { r.mu.Lock() defer r.mu.Unlock() @@ -113,8 +118,8 @@ func (r *statsRig) published() []published { func waitFor(t *testing.T, what string, cond func() bool) { t.Helper() - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { + limit := time.Now().Add(5 * time.Second) + for time.Now().Before(limit) { if cond() { return } @@ -194,8 +199,8 @@ func TestStatsTickNeverResetsTheScanInterval(t *testing.T) { func TestStatsTickSuppressesAnEmptyDelta(t *testing.T) { r := newStatsRig(t) fixed := map[int]ports.ProcSample{ - 100: {CPUPercent: 1, MemoryRSS: 1 << 20, ThreadCount: 4, State: "running", Uptime: "1s"}, - 200: {CPUPercent: 2, MemoryRSS: 2 << 20, ThreadCount: 9, State: "running", Uptime: "1s"}, + 101: {CPUPercent: 1, MemoryRSS: 1 << 20, ThreadCount: 4, State: "running", Uptime: "1s"}, + 202: {CPUPercent: 2, MemoryRSS: 2 << 20, ThreadCount: 9, State: "running", Uptime: "1s"}, } r.loop.opts.SampleStats = func([]int) map[int]ports.ProcSample { return fixed } @@ -219,25 +224,25 @@ func TestStatsTickSuppressesAnEmptyDelta(t *testing.T) { // TestStatsTickHandlesAPIDDisappearing: a vanished pid keeps its last stats; // zeroes would read like facts, and removing the row is the scan's job. func TestStatsTickHandlesAPIDDisappearing(t *testing.T) { - r := newStatsRig(t) - r.loop.scanAndPublish(Include{Stats: true}) - r.loop.sampleStats(Include{Stats: true}) - before := r.loop.Cached() + rig := newStatsRig(t) + rig.loop.scanAndPublish(Include{Stats: true}) + rig.loop.sampleStats(Include{Stats: true}) + before := rig.loop.Cached() - r.mu.Lock() - r.gone[100] = true - r.mu.Unlock() - r.loop.sampleStats(Include{Stats: true}) - after := r.loop.Cached() + rig.mu.Lock() + rig.gone[101] = true + rig.mu.Unlock() + rig.loop.sampleStats(Include{Stats: true}) + after := rig.loop.Cached() if len(after.Ports) != len(before.Ports) { t.Fatalf("the stats tick removed a row: %d -> %d", len(before.Ports), len(after.Ports)) } - gone, alive := after.Ports[0], after.Ports[1] - if gone.PID != 100 || alive.PID != 200 { + vanished, alive := after.Ports[0], after.Ports[1] + if vanished.PID != 101 || alive.PID != 202 { t.Fatalf("unexpected row order: %+v", after.Ports) } - if *gone.Stats != *before.Ports[0].Stats { + if *vanished.Stats != *before.Ports[0].Stats { t.Errorf("a vanished pid's stats were rewritten") } if *alive.Stats == *before.Ports[1].Stats { @@ -252,15 +257,15 @@ func TestStatsTickIsSerializedWithScans(t *testing.T) { r.run(t) r.subscribe(true) - var wg sync.WaitGroup - stop := make(chan struct{}) + var workers sync.WaitGroup + quit := make(chan struct{}) for range 4 { - wg.Add(1) + workers.Add(1) go func() { - defer wg.Done() + defer workers.Done() for { select { - case <-stop: + case <-quit: return default: } @@ -276,8 +281,8 @@ func TestStatsTickIsSerializedWithScans(t *testing.T) { waitFor(t, "five samples and two publishes under load", func() bool { return r.samples.Load() >= 5 && len(r.published()) >= 2 }) - close(stop) - wg.Wait() + close(quit) + workers.Wait() seen := r.published() if len(seen) < 2 { @@ -298,44 +303,44 @@ func TestStatsTickRunsWhileAScanIsInFlight(t *testing.T) { r := newStatsRig(t) r.loop.scanAndPublish(Include{Stats: true}) - scanning := make(chan struct{}) - release := make(chan struct{}) - var once sync.Once - rows := r.rows + entered := make(chan struct{}) + proceed := make(chan struct{}) + var first sync.Once + fixed := r.rows r.loop.opts.Scan = func(Include) ([]ports.ListeningPort, error) { - once.Do(func() { - close(scanning) - <-release + first.Do(func() { + close(entered) + <-proceed }) - return append([]ports.ListeningPort(nil), rows...), nil + return append([]ports.ListeningPort(nil), fixed...), nil } - scanDone := make(chan struct{}) + scanOver := make(chan struct{}) go func() { - defer close(scanDone) + defer close(scanOver) r.loop.scanAndPublish(Include{Stats: true}) }() - <-scanning + <-entered before := r.loop.Cached().Seq - done := make(chan struct{}) + tickDone := make(chan struct{}) go func() { - defer close(done) + defer close(tickDone) r.loop.sampleStats(Include{Stats: true}) }() select { - case <-done: + case <-tickDone: case <-time.After(2 * time.Second): - close(release) - <-scanDone + close(proceed) + <-scanOver t.Fatal("the stats tick blocked behind a scan still doing OS work") } if got := r.loop.Cached().Seq; got <= before { t.Errorf("seq = %d, want more than %d", got, before) } - close(release) - <-scanDone + close(proceed) + <-scanOver } // TestStatsSkipRowsThatAreNotThisMachines: a container's numbers come from diff --git a/engine/internal/store/migrate_ext_test.go b/engine/internal/store/migrate_ext_test.go index fc82d778..45cdc12e 100644 --- a/engine/internal/store/migrate_ext_test.go +++ b/engine/internal/store/migrate_ext_test.go @@ -15,8 +15,8 @@ const reservedSharesDDL = ` CREATE TABLE shares ( id TEXT PRIMARY KEY, port INTEGER NOT NULL, - url TEXT NOT NULL, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + url TEXT NOT NULL );` func TestReservedMigrationFromAnotherPackage(t *testing.T) { From 2319df6dabcaa645d54550d79a482b946f594c39 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 15:04:22 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(engine):=20=E8=AE=B0=E5=BD=95=20scanne?= =?UTF-8?q?r=20=E4=B8=8E=20store=20=E9=87=8D=E5=86=99=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E5=A4=8D=E5=88=B6=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 26dbcae:总量 6,307 → 5,530 行 / 328 段、60 个文件(生产 2,073、 测试 3,457)。除 internal/daemon 外各包已归零。 Co-Authored-By: Claude Code --- engine/UPSTREAM.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/engine/UPSTREAM.md b/engine/UPSTREAM.md index 4564dadc..7e69a5aa 100644 --- a/engine/UPSTREAM.md +++ b/engine/UPSTREAM.md @@ -31,25 +31,27 @@ **9,621**(对照 `f4c50506`)、**9,659**(对照 `be48afbe`),生产 3,852、 测试 5,758。 -2026-11-12 的 ports、killer、groups 与 cmd 重写在此基础上继续下降:对照 -`26dbcae` 为 **6,307** 行 / 410 段,分布在 75 个文件(生产 2,305、测试 4,002)。 +2026-11-12 的 ports、killer、groups、cmd、scanner 与 store 重写在此基础上继续 +下降:对照 `26dbcae` 为 **5,530** 行 / 328 段,分布在 60 个文件(生产 2,073、 +测试 3,457)。只有 `internal/daemon` 仍有复制段。 -按包(对照 `26dbcae`,ports、killer、groups 与 cmd 重写后): +按包(对照 `26dbcae`,除 daemon 外已重写): | 包 | 生产 | 测试 | 合计 | | --- | ---: | ---: | ---: | | `internal/daemon` | 2,073 | 3,457 | 5,530 | | `internal/cmd` | 0 | 0 | 0 | | `internal/groups` | 0 | 0 | 0 | -| `internal/scanner` | 220 | 540 | 760 | +| `internal/scanner` | 0 | 0 | 0 | | `internal/ports` | 0 | 0 | 0 | | `internal/killer` | 0 | 0 | 0 | -| `internal/store` | 12 | 5 | 17 | +| `internal/store` | 0 | 0 | 0 | `internal/claims`、`internal/cmd`、`internal/display`、`internal/testenv`、 -`internal/groups`、`internal/ports`、`internal/killer` 与 -`internal/store/claims*.go` 已经归零;`internal/docker` 只剩结构体字段与表驱动 -用例数据。`internal/store` 已整包重写:生产代码从 1,076 行降到 199 行。 +`internal/groups`、`internal/killer`、`internal/ports`、`internal/scanner`、 +`internal/store` 与 `internal/store/claims*.go` 已经归零;`internal/docker` 只剩 +结构体字段与表驱动用例数据。`internal/store` 已整包重写:生产代码从 1,076 行降到 +199 行。 ```sh git clone https://github.com/raskrebs/sonar /tmp/sonar