Files
zeekayandHanzo Dev 45bff3f5b6 fix(dex): close price sources synchronously; build pkg/dpdk and pkg/orderbook on linux
`go test ./...` failed three ways on the runner. Two were build failures that
cannot happen on a mac, and one was a race that only loses under load.

pkg/dpdk — `undefined: syscall.SO_NOSIGPIPE`. DarwinOptimizations guarded itself
with `if runtime.GOOS != "darwin" { return }`, but SO_NOSIGPIPE is a COMPILE-time
constant that exists only on Darwin; a runtime check cannot save a reference the
linux build still has to resolve. Split into _darwin.go / _nodarwin.go. Verified
by cross-compiling both ways -- it failed for GOOS=linux before, both pass now.

pkg/orderbook — the cgo preamble uses uint64_t while including only <stdlib.h>.
macOS pulls stdint.h in transitively and glibc does not, so this built here and
never on the runner. Added the include.

pkg/price — TestXChainSourceStalePrice: "Expected price to be stale".

Not a flaky test; Close() was not a barrier. It closed the done channel and
returned WITHOUT waiting for the polling goroutine. A loop selecting on done
against a ticker picks a ready case AT RANDOM, so a poll could still be running
after Close() returned, and poll writes last[symbol] = time.Now(). The test
closes the source, backdates that timestamp by 10s, and asserts staleness -- an
in-flight poll overwrote it. Wide window on a loaded runner, near-zero on a
laptop, which is why it reproduces 0/30 here.

All EIGHT sources had it, so the fix is one shared `lifecycle` (embedded, so
`s.done` still resolves) whose stop() joins before returning and is safe to call
twice. Oracle had the same defect written as

    // Give time for goroutines to exit
    time.Sleep(100 * time.Millisecond)

which could not work either -- calcAverages blocks on a 1s ticker, so it ran up
to a full second after Stop() returned. Now on the same lifecycle, and its loops
select on done instead of polling a flag, so they leave promptly rather than
after one more tick. Nothing restarts a stopped source (checked: 0 tests), and
the other sources already could not.

    go build ./...                        OK
    GOOS=linux go build ./...             OK   (failed before)
    go vet price/dpdk/orderbook           OK
    go test ./pkg/price/ -race            ok, 3/3 runs
    go test -run StalePrice -count=30     ok

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 03:19:43 -07:00

42 lines
1.4 KiB
Go

package price
import "sync"
// lifecycle is the one start/stop mechanism for a polling price source.
//
// Every source here had written its own, and every one had the same hole:
// Close() closed the done channel and returned immediately, without waiting for
// the goroutine to notice. A polling loop selects on done against a ticker, and
// when both are ready Go picks a case AT RANDOM — so a poll could still be
// running, and still writing to the source's maps, after Close() had returned.
//
// TestXChainSourceStalePrice is what surfaced it: the test closes the source,
// backdates last["LUX-USDC"] by 10s, and asserts the price reads stale. An
// in-flight poll overwrote that timestamp with time.Now() and the assertion
// failed — on CI, where scheduling delays widen the window, not on a laptop.
//
// stop() joins before returning, so a closed source cannot mutate anything, and
// it is safe to call more than once (close of a closed channel panics).
type lifecycle struct {
done chan struct{}
wg sync.WaitGroup
once sync.Once
}
func newLifecycle() lifecycle { return lifecycle{done: make(chan struct{})} }
// run starts fn in a goroutine that stop() will wait for.
func (l *lifecycle) run(fn func()) {
l.wg.Add(1)
go func() {
defer l.wg.Done()
fn()
}()
}
// stop signals every goroutine started by run and waits for them to return.
func (l *lifecycle) stop() {
l.once.Do(func() { close(l.done) })
l.wg.Wait()
}