Compare commits
44
Commits
001ccd4622
...
0d4daeb45a
+16
-1
@@ -70,6 +70,10 @@ type Barnard struct {
|
||||
// Added for file playback
|
||||
FileStream *fileplayback.Player
|
||||
FileStreamMutex sync.Mutex
|
||||
// stereoEncoder is reused across connections. Each one holds a little
|
||||
// under a megabyte of encoder state, so building a fresh one per
|
||||
// reconnect is pure churn; it is reset when file playback ends.
|
||||
stereoEncoder gumble.AudioEncoder
|
||||
|
||||
// Added for tone test mode (bypasses all soundcard/OpenAL)
|
||||
ToneTest bool
|
||||
@@ -91,6 +95,8 @@ type Barnard struct {
|
||||
|
||||
reconnectStop chan struct{}
|
||||
reconnectStopOnce sync.Once
|
||||
reconnectMutex sync.Mutex
|
||||
reconnecting bool
|
||||
}
|
||||
|
||||
// cleanupConnectionAudio releases connection-owned audio resources before a
|
||||
@@ -114,11 +120,20 @@ func (b *Barnard) cleanupConnectionAudio() {
|
||||
b.connectionMutex.Unlock()
|
||||
}
|
||||
|
||||
func (b *Barnard) cleanupToneTestAudio() {
|
||||
// detachToneTestAudio unsubscribes the saver without closing its output, so a
|
||||
// reconnect can re-attach the same file. The saver's output is opened
|
||||
// exclusively and cannot be reopened.
|
||||
func (b *Barnard) detachToneTestAudio() {
|
||||
if b.toneTestSaverDetach != nil {
|
||||
b.toneTestSaverDetach.Detach()
|
||||
b.toneTestSaverDetach = nil
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupToneTestAudio detaches the saver and closes its output. Use it when
|
||||
// the client is shutting down, not between connections.
|
||||
func (b *Barnard) cleanupToneTestAudio() {
|
||||
b.detachToneTestAudio()
|
||||
if b.toneTestSaver != nil {
|
||||
b.toneTestSaver.Stop()
|
||||
b.toneTestSaver = nil
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
AudioDriver = 'pipewire'
|
||||
MicVolume = 1.0
|
||||
InputDevice = ''
|
||||
OutputDevice = ''
|
||||
DefaultServer = 'mumble.the-brannons.com:64738'
|
||||
Username = 'bmc-beta'
|
||||
NotifyCommand = '/usr/share/barnard/barnard-sound.sh "%event" "%who" "%what"'
|
||||
NoiseSuppressionEnabled = false
|
||||
Certificate = ''
|
||||
RecordingFormat = 'flac'
|
||||
RecordingDirectory = '~/Audio'
|
||||
|
||||
[Hotkeys]
|
||||
Talk = 'f8'
|
||||
VolumeDown = 'f5'
|
||||
VolumeUp = 'f6'
|
||||
VolumeReset = 'f7'
|
||||
MuteToggle = 'f4'
|
||||
RecordToggle = 'ctrl_r'
|
||||
Exit = 'f10'
|
||||
ToggleTimestamps = 'f3'
|
||||
SwitchViews = 'tab'
|
||||
ScrollUp = 'pgup'
|
||||
ScrollDown = 'pgdn'
|
||||
AdminMenu = 'f11'
|
||||
NoiseSuppressionToggle = 'f9'
|
||||
|
||||
[[Servers]]
|
||||
Host = 'mumble.the-brannons.com'
|
||||
Port = 64738
|
||||
|
||||
[[Servers.Users]]
|
||||
Username = 'bmc-beta'
|
||||
Boost = 1
|
||||
Volume = 1.0
|
||||
LocallyMuted = false
|
||||
@@ -61,15 +61,24 @@ func (b *Barnard) connect(reconnect bool) bool {
|
||||
// --- Tone test mode: skip all OpenAL; generate 440 Hz tone
|
||||
// --- and save incoming audio to a file.
|
||||
|
||||
// Open the output first. Starting transmission before this succeeds
|
||||
// leaves an orphaned tone goroutine when the path is unusable.
|
||||
saver, err := NewAudioFileSaver(b.ToneTestOutput)
|
||||
if err != nil {
|
||||
b.exitWithError(err)
|
||||
return false
|
||||
// The output is reserved exclusively, so it can only be opened once.
|
||||
// A reconnect keeps writing to the saver opened for the first
|
||||
// connection instead of failing on the file that already exists.
|
||||
if b.toneTestSaver == nil {
|
||||
// Open the output first. Starting transmission before this
|
||||
// succeeds leaves an orphaned tone goroutine when the path is
|
||||
// unusable.
|
||||
saver, err := NewAudioFileSaver(b.ToneTestOutput)
|
||||
if err != nil {
|
||||
b.exitWithError(err)
|
||||
return false
|
||||
}
|
||||
b.toneTestSaver = saver
|
||||
}
|
||||
b.toneTestSaver = saver
|
||||
b.toneTestSaverDetach = b.Client.Config.AttachAudio(saver)
|
||||
// Detach any registration left over from the previous connection so
|
||||
// the shared audio listener list does not grow once per reconnect.
|
||||
b.detachToneTestAudio()
|
||||
b.toneTestSaverDetach = b.Client.Config.AttachAudio(b.toneTestSaver)
|
||||
|
||||
b.setConnected(true)
|
||||
if b.toneTestAutoTransmit() {
|
||||
@@ -99,11 +108,16 @@ func (b *Barnard) connect(reconnect bool) bool {
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize stereo encoder for file playback
|
||||
b.Client.SetStereoEncoder(opus.NewStereoEncoder())
|
||||
// Initialize stereo encoder for file playback, reusing the one built for
|
||||
// the previous connection rather than allocating another.
|
||||
if b.stereoEncoder == nil {
|
||||
b.stereoEncoder = opus.NewStereoEncoder()
|
||||
}
|
||||
b.Client.SetStereoEncoder(b.stereoEncoder)
|
||||
|
||||
// Initialize file player
|
||||
b.FileStreamMutex.Lock()
|
||||
previousFile := b.FileStream
|
||||
b.FileStream = fileplayback.New(b.Client)
|
||||
b.FileStream.SetErrorFunc(func(err error) {
|
||||
// Disable stereo when file finishes or errors
|
||||
@@ -113,9 +127,25 @@ func (b *Barnard) connect(reconnect bool) bool {
|
||||
stream.SetFilePlayer(b.FileStream)
|
||||
b.FileStreamMutex.Unlock()
|
||||
b.connectionMutex.Lock()
|
||||
previousStream := b.Stream
|
||||
b.Stream = stream
|
||||
b.connectionMutex.Unlock()
|
||||
|
||||
// A disconnect that lands while the OpenAL devices are opening starts a
|
||||
// second reconnect, so two connects can race to install a stream. The one
|
||||
// that loses must be released here: an orphaned stream keeps its OpenAL
|
||||
// device, its render thread and — because only Destroy detaches it — its
|
||||
// entry in the shared audio listener list, so every later audio packet
|
||||
// from every user is dispatched to it as well, for the life of the
|
||||
// process. Release outside the locks, since Destroy waits on the
|
||||
// per-user audio goroutines.
|
||||
if previousFile != nil {
|
||||
_ = previousFile.Stop()
|
||||
}
|
||||
if previousStream != nil {
|
||||
previousStream.Destroy()
|
||||
}
|
||||
|
||||
b.setConnected(true)
|
||||
// Dial delivers OnConnect before connect creates the OpenAL stream, so
|
||||
// start auto-transmit here as well for initial connections and reconnects.
|
||||
@@ -208,7 +238,9 @@ func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
|
||||
close(b.toneTestStop)
|
||||
b.toneTestStop = nil
|
||||
}
|
||||
b.cleanupToneTestAudio()
|
||||
// Keep the saver's output open: it was reserved exclusively and a
|
||||
// reconnect re-attaches the same file. It is closed on exit.
|
||||
b.detachToneTestAudio()
|
||||
}
|
||||
|
||||
b.Notify("disconnect", "me", reason)
|
||||
@@ -223,7 +255,27 @@ func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
|
||||
b.UiTree.Rebuild()
|
||||
b.Ui.Refresh()
|
||||
})
|
||||
go b.reconnectGoroutine()
|
||||
b.startReconnect()
|
||||
}
|
||||
|
||||
// startReconnect launches the reconnect loop unless one is already running.
|
||||
// Disconnect notifications can arrive more than once for a connection, and
|
||||
// every extra loop is another connect racing to install its own audio stream.
|
||||
func (b *Barnard) startReconnect() {
|
||||
b.reconnectMutex.Lock()
|
||||
defer b.reconnectMutex.Unlock()
|
||||
if b.reconnecting {
|
||||
return
|
||||
}
|
||||
b.reconnecting = true
|
||||
go func() {
|
||||
defer func() {
|
||||
b.reconnectMutex.Lock()
|
||||
b.reconnecting = false
|
||||
b.reconnectMutex.Unlock()
|
||||
}()
|
||||
b.reconnectGoroutine()
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *Barnard) reconnectGoroutine() {
|
||||
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
# barnard-memwatch.sh
|
||||
# Description: Records barnard's memory use alongside its Go heap size, and
|
||||
# captures profiles before the kernel's OOM killer can take the evidence away.
|
||||
#
|
||||
# Start barnard with -profile, then run this alongside it. The ratio between
|
||||
# the two numbers is the diagnosis:
|
||||
#
|
||||
# RSS large, go_heap small -> the growth is in cgo memory (OpenAL, opus,
|
||||
# rnnoise). The Go garbage collector cannot see
|
||||
# it and so applies no back pressure at all.
|
||||
# RSS large, go_heap large -> the growth is Go-side, and the heap profile
|
||||
# this script dumps names what is holding it.
|
||||
#
|
||||
# Usage: barnard-memwatch.sh [output-log]
|
||||
#
|
||||
# Copyright 2026, Storm Dragon, <storm_dragon@linux-a11y.org>
|
||||
#
|
||||
# This is free software; you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free
|
||||
# Software Foundation; either version 3, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# This software is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
|
||||
set -u
|
||||
|
||||
# barnard serves its profiles here when started with -profile.
|
||||
profile_host="localhost:6060"
|
||||
# Dump profiles once resident memory passes this many kilobytes.
|
||||
dump_threshold_kb=2000000
|
||||
sample_interval=30
|
||||
|
||||
output="${1:-$HOME/barnard-memwatch.log}"
|
||||
|
||||
pid="$(pgrep -n -x barnard)" || {
|
||||
echo "barnard is not running" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! curl -s -m 2 "http://${profile_host}/debug/pprof/" > /dev/null; then
|
||||
echo "no profile server on ${profile_host}; start barnard with -profile" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "watching barnard (pid ${pid}), writing to ${output}"
|
||||
|
||||
dumped=0
|
||||
while kill -0 "$pid" 2> /dev/null; do
|
||||
rss="$(awk '/^VmRSS/{print $2}' "/proc/${pid}/status" 2> /dev/null)"
|
||||
swap="$(awk '/^VmSwap/{print $2}' "/proc/${pid}/status" 2> /dev/null)"
|
||||
heap="$(curl -s -m 2 "http://${profile_host}/debug/pprof/heap?debug=1" \
|
||||
| awk '/^# HeapInuse/{print $4}')"
|
||||
goroutines="$(curl -s -m 2 "http://${profile_host}/debug/pprof/goroutine?debug=1" \
|
||||
| head -1 | grep -o '[0-9]*')"
|
||||
threads="$(ls "/proc/${pid}/task" 2> /dev/null | wc -l)"
|
||||
|
||||
printf '%s rss=%skB swap=%skB go_heap=%sB goroutines=%s threads=%s\n' \
|
||||
"$(date +%T)" "${rss:-?}" "${swap:-0}" "${heap:-?}" \
|
||||
"${goroutines:-?}" "${threads}" >> "$output"
|
||||
|
||||
# Capture the evidence once, while the process is still alive to ask.
|
||||
if [[ ${dumped} -eq 0 && ${rss:-0} -gt ${dump_threshold_kb} ]]; then
|
||||
dumped=1
|
||||
curl -s -m 10 -o "${output}.heap" \
|
||||
"http://${profile_host}/debug/pprof/heap"
|
||||
curl -s -m 10 -o "${output}.goroutine" \
|
||||
"http://${profile_host}/debug/pprof/goroutine?debug=2"
|
||||
cp "/proc/${pid}/smaps_rollup" "${output}.smaps" 2> /dev/null
|
||||
printf '%s *** dumped profiles at rss=%skB ***\n' \
|
||||
"$(date +%T)" "${rss}" >> "$output"
|
||||
fi
|
||||
|
||||
sleep "${sample_interval}"
|
||||
done
|
||||
|
||||
echo "barnard exited; log is in ${output}"
|
||||
@@ -1,308 +0,0 @@
|
||||
Barnard audit findings
|
||||
======================
|
||||
|
||||
This list was assembled from static review of the maintained Go sources,
|
||||
including Barnard, gumble, gumbleopenal, the local OpenAL binding, recording,
|
||||
file playback, UI, and protocol code. Generated protobuf output and the
|
||||
vendored Mumble C++ source were not treated as files to modify.
|
||||
|
||||
Priority 0: security and crashers
|
||||
---------------------------------
|
||||
|
||||
[x] 1. UDP crypto secrets are logged
|
||||
Files: gumble/gumble/udp15.go, gumble/gumble/crypt.go
|
||||
Both crypto setup paths log the AES key and IV/nonce material at debug
|
||||
level. Anyone who obtains debug logs and a packet capture can decrypt
|
||||
voice traffic. Remove all secret material from logs. At most log lengths,
|
||||
setup success, and a non-secret connection identifier.
|
||||
|
||||
[x] 2. Native UDP races TCP state mutation
|
||||
Files: gumble/gumble/udp15.go, gumble/gumble/handlers.go,
|
||||
gumble/gumble/audiolisteners.go
|
||||
The UDP reader runs independently of the TCP read routine. It reads
|
||||
Client.Users, User.decoder/audio sequence state, and audio-listener stream
|
||||
maps while TCP handlers add/remove users and close stream channels. This
|
||||
can cause data races, concurrent map read/write panics, or sends to a
|
||||
closed stream channel. Establish one synchronization regime: protect
|
||||
client user/channel state and audio stream registration with locks, and
|
||||
ensure channel close/send are serialized. Do not rely on the TCP read
|
||||
routine being serialized with UDP.
|
||||
|
||||
[x] 3. Context actions panic on receipt and on trigger
|
||||
Files: gumble/gumble/client.go, gumble/gumble/handlers.go,
|
||||
gumble/gumble/contextaction.go
|
||||
Client.ContextActions is never initialized, so the first
|
||||
ContextActionModify_Add writes to a nil map. Further, newly created
|
||||
ContextAction values do not receive client = c, so Trigger methods dereference
|
||||
nil. Initialize the map in DialWithDialer and assign its owning client when
|
||||
actions are created. Add handler tests for add/remove/trigger.
|
||||
|
||||
[x] 4. Unknown ChannelId deadlocks the protocol reader
|
||||
File: gumble/gumble/handlers.go
|
||||
In handleUserState, the unknown ChannelId branch takes c.volatile.Lock()
|
||||
again instead of unlocking before returning. This leaves the mutex locked
|
||||
forever. Replace with one unlock (prefer defer after acquisition) and add
|
||||
a malformed/out-of-order channel test.
|
||||
|
||||
[x] 5. OpenAL Buffer.Delete deletes a source, not a buffer
|
||||
File: gumble/go-openal/openal/buffer.go
|
||||
Buffer.Delete calls C.walDeleteSource. It must call C.walDeleteBuffer.
|
||||
The current code reports invalid source errors and leaks OpenAL buffers.
|
||||
Add a binding test that creates and deletes a single buffer and checks
|
||||
openal.Err().
|
||||
|
||||
[x] 6. UI writes are concurrent and termbox is not protected
|
||||
Files: ui.go, client.go, gumble/gumbleopenal/stream.go, uiterm/*.go
|
||||
Network callbacks, audio capture error callbacks, and reconnect goroutines
|
||||
directly update Ui, Textview, Tree, Label, and termbox while Ui.Run updates
|
||||
the same state. These types have no locks and termbox calls are not safe
|
||||
from arbitrary goroutines. Route UI work through a UI-owned event queue, or
|
||||
protect all state and ensure only the UI goroutine calls termbox.
|
||||
|
||||
[x] 7. Terminal control sequences from server data are rendered
|
||||
Files: client.go, ui.go, ui_tree.go, admin.go
|
||||
HTML escaping does not remove terminal escape/control sequences. Server
|
||||
supplied messages, names, comments, and channel names are displayed in the
|
||||
terminal and can contain ANSI/OSC controls. Sanitize for terminal display:
|
||||
remove/control-escape C0, DEL, ESC, and dangerous Unicode controls before
|
||||
rendering or notifying.
|
||||
|
||||
Priority 1: transport, lifecycle, and correctness
|
||||
---------------------------------------------------
|
||||
|
||||
[x] 8. TCP audio is discarded before UDP is proven usable
|
||||
Files: gumble/gumble/crypt.go, gumble/gumble/client.go, gumble/gumble/udp.go
|
||||
udpActive is set immediately after CryptSetup. The TCP read routine then
|
||||
discards UDPTunnel packets even if inbound UDP is blocked or NAT setup has
|
||||
failed. Mark UDP active only after an authenticated UDP response/audio
|
||||
packet (or retain TCP until confirmed), and define fallback/recovery rules.
|
||||
|
||||
[x] 9. Stream capture shutdown/startup races OpenAL
|
||||
File: gumble/gumbleopenal/stream.go
|
||||
StopSource closes a channel but does not wait for sourceRoutine. Destroy
|
||||
immediately closes the capture device, so the routine can access a closed
|
||||
device. A quick stop/start can also run two capture routines at once. Use
|
||||
a cancellation context plus WaitGroup/done channel; serialize Start, Stop,
|
||||
reopen, and Destroy; wait before CaptureCloseDevice.
|
||||
|
||||
[x] 10. Renderer can be used after it is closed
|
||||
Files: gumble/gumbleopenal/stream.go, gumble/gumble/audiolisteners.go
|
||||
Existing OnAudioStream goroutines can run cleanup after Destroy closes
|
||||
renderCh. Their final render call then panics sending on a closed channel.
|
||||
Stop and join all audio stream goroutines before renderer shutdown; make
|
||||
render reject work after shutdown without panicking.
|
||||
|
||||
[x] 11. Reconnect leaks the old audio stream
|
||||
File: client.go
|
||||
OnDisconnect starts reconnecting but never destroys the existing Stream or
|
||||
stops its file player/capture routine. connect creates a new Stream and
|
||||
overwrites b.Stream. Destroy/stop the old resources before reconnecting;
|
||||
make disconnect cleanup idempotent.
|
||||
|
||||
[x] 12. File player sessions race each other
|
||||
File: fileplayback/player.go
|
||||
readFileAudio repeatedly reads mutable Player stopChan/ctx/audioChan.
|
||||
Stop can return and a new PlayFile can replace them while the old ffmpeg
|
||||
goroutine is still running. Old audio can enter the new session and old
|
||||
workers can survive. Put per-playback state in a session object with local
|
||||
context, stop channel, output channel, and WaitGroup. Stop must cancel and
|
||||
join that session before another begins.
|
||||
|
||||
[x] 13. Recorder Stop races the encoder writer
|
||||
File: recording/recorder.go
|
||||
Stop closes stdin while run may be writing. A normal stop can therefore
|
||||
record a closed-pipe error and be reported as failed. Have run own stdin
|
||||
closure: signal stop, wait for run to finish/close stdin and Wait ffmpeg,
|
||||
then return its result. Do not close stdin concurrently from Stop.
|
||||
|
||||
[x] 14. Tone-test startup leaks transmission on output-file error
|
||||
File: client.go
|
||||
connect starts StartToneGenerator and sets Tx before NewAudioFileSaver. If
|
||||
output file creation fails, the tone goroutine continues. Create the saver
|
||||
first, or close/wait for the tone generator and reset Tx on every failure.
|
||||
|
||||
[x] 15. Gumble ffmpeg Pause can block forever
|
||||
File: gumble/gumbleffmpeg/stream.go
|
||||
Pause checks StatePlaying, releases the lock, then sends on an unbuffered
|
||||
pause channel. If process exits in between, no receiver remains. Redesign
|
||||
around context cancellation/state guarded by a mutex and a per-run done
|
||||
channel. Also synchronize Volume, which is currently read and written
|
||||
without protection.
|
||||
|
||||
[x] 16. Audio listener/event listener detach is not concurrency-safe
|
||||
Files: gumble/gumble/listeners.go, gumble/gumble/audiolisteners.go
|
||||
Event listener detach has no lock; audio detach removes streams without
|
||||
closing/joining them. Concurrent attach/detach/delivery can corrupt linked
|
||||
lists or strand goroutines. Use mutex-protected listener snapshots and an
|
||||
idempotent detach operation.
|
||||
|
||||
[x] 17. Notification commands block callers and substitution is unsafe
|
||||
File: main.go
|
||||
Notify sends to an unbuffered channel. The one consumer waits for each
|
||||
shell command, so slow notification programs block UI/network callbacks.
|
||||
Use a bounded queue and define dropping/backpressure behavior. Placeholder
|
||||
replacement is sequential: a user-controlled value containing a later
|
||||
placeholder can be re-expanded inside prior substituted text. Build argv
|
||||
without a shell where possible, or perform non-recursive token expansion
|
||||
in one pass.
|
||||
|
||||
Priority 2: protocol and data correctness
|
||||
------------------------------------------
|
||||
|
||||
[x] 18. UserStats FromServer fields are copied from FromClient
|
||||
File: gumble/gumble/handlers.go
|
||||
In handleUserStats, FromServer.Good is correct but Late/Lost/Resync read
|
||||
packet.FromClient. Use packet.FromServer for all four fields and add a
|
||||
regression test with differing values.
|
||||
|
||||
[x] 19. Full channel link updates leave stale reverse links
|
||||
File: gumble/gumble/handlers.go
|
||||
A ChannelState Links replacement assigns a new channel.Links map but does
|
||||
not remove channel from the Links maps of old peers. Remove reciprocal old
|
||||
links before replacement and add link add/remove/full-replacement tests.
|
||||
|
||||
[x] 20. Malformed protobuf fields can panic handlers
|
||||
File: gumble/gumble/handlers.go
|
||||
Several optional proto fields are dereferenced without validation, notably
|
||||
ACL group.Name and UserList_User.UserId. Validate required fields before
|
||||
dereferencing and return errInvalidProtobuf for malformed server packets.
|
||||
Audit all packet pointer dereferences similarly.
|
||||
|
||||
[x] 21. UDP protocol state has no complete interoperability test coverage
|
||||
Files: gumble/gumble/udp15.go, gumble/gumble/udp.go
|
||||
Tests are mostly local encrypt/decrypt round trips. Add captured/reference
|
||||
vectors from current Mumble for CryptSetup, encrypted audio, ping, packet
|
||||
loss, IV wrap, late/replayed packets, protobuf and legacy envelopes, frame
|
||||
terminators, positional data, and volume adjustment. Test real UDP
|
||||
fallback behavior too.
|
||||
|
||||
[x] 22. Opus bitrate calculation assumes a 10 ms interval
|
||||
File: gumble/opus/opus.go
|
||||
bitrate is maxDataBytes * 8 * 100. For permitted 20/40/60 ms intervals it
|
||||
is 2x/4x/6x too high. Calculate bits per frame divided by the actual
|
||||
Config.AudioInterval, or set the bitrate once when configuration changes.
|
||||
|
||||
[x] 23. AudioInterval accepts invalid values
|
||||
File: gumble/gumble/config.go
|
||||
AudioFrameSize truncates arbitrary intervals to a count of 10 ms frames,
|
||||
while the ticker still uses the original interval. Validate and reject
|
||||
values other than 10/20/40/60 ms (and validate AudioDataBytes/Buffers).
|
||||
|
||||
[x] 24. Legacy/custom varint has a MinInt64 recursion failure
|
||||
File: gumble/gumble/varint/write.go
|
||||
Encoding math.MinInt64 evaluates -value to the same negative number and
|
||||
recursively encodes forever until panic. Handle MinInt64 explicitly or
|
||||
encode negatives using an unsigned magnitude without overflow. Validate
|
||||
output buffer capacity in the exported encoder too.
|
||||
|
||||
[x] 25. Mumble version layout documentation is wrong
|
||||
File: gumble/gumble/version.go
|
||||
The comment says major uses bits 0-15, but SemanticVersion and ClientVersion
|
||||
use bits 16-31. Correct the documentation and add known version tests.
|
||||
|
||||
Priority 3: configuration, UI, and binding hardening
|
||||
------------------------------------------------------
|
||||
|
||||
[x] 26. Persisted microphone volume is unused and zero is impossible
|
||||
Files: config/user_config.go, ui.go, gumble/gumbleopenal/stream.go
|
||||
MicVolume is stored but never applied when a Stream is created; UI changes
|
||||
do not call SaveConfig. GetMicVolume treats stored zero bits as
|
||||
uninitialized and returns 1.0, so mute cannot persist. Initialize the
|
||||
atomic value to 1.0 in New, apply configured volume during connect, allow
|
||||
zero, and save volume changes.
|
||||
|
||||
[x] 27. Config address and file error handling can panic
|
||||
File: config/user_config.go
|
||||
makeHostPort splits on ':' and panics for malformed addresses or IPv6.
|
||||
fileExists dereferences info after non-ENOENT Stat failures. Replace with
|
||||
net.SplitHostPort (with explicit default-port policy) and return/report
|
||||
errors from Stat rather than dereferencing nil.
|
||||
|
||||
[x] 28. Config SaveConfig panics and is not robust
|
||||
File: config/user_config.go
|
||||
Configuration write/rename errors panic the client. Return errors to the
|
||||
caller, preserve the prior config on failure, and consider fsyncing the
|
||||
temporary file/directory before rename. Avoid broad unrelated formatting
|
||||
changes while fixing this.
|
||||
|
||||
[x] 29. FIFO reader spins after an error
|
||||
File: main.go
|
||||
setup_fifo ignores all ReadBytes errors and retries immediately. It also
|
||||
never closes the FIFO descriptor. Exit the reader on terminal errors,
|
||||
close the descriptor, and make shutdown cancellable.
|
||||
|
||||
[x] 30. Empty UI tree can panic; UI startup errors are swallowed
|
||||
Files: uiterm/tree.go, uiterm/ui.go
|
||||
Tree.uiKeyEvent indexes lines[activeLine] for a non-arrow key even when
|
||||
no lines exist. Guard empty trees. Ui.Run returns nil when termbox.Init
|
||||
fails, hiding startup failure. Return that error. Also stop/join the
|
||||
PollEvent goroutine on UI shutdown and make Close nonblocking/idempotent.
|
||||
|
||||
[x] 31. Text UI is not Unicode-safe and timestamp parsing is fragile
|
||||
Files: uiterm/textbox.go, uiterm/textview.go
|
||||
Textbox cursor positions are byte offsets but editing/display iterates
|
||||
runes, so non-ASCII input can be split into invalid UTF-8. Textview assumes
|
||||
every line contains ']' when timestamps are hidden and can panic otherwise.
|
||||
Track rune boundaries and use safe timestamp parsing/fallbacks.
|
||||
|
||||
[x] 32. OpenAL binding needs API and unsafe hardening
|
||||
Files: gumble/go-openal/openal/*.go
|
||||
Many public slice APIs unconditionally use &slice[0] and panic for empty
|
||||
input (NewBuffers(0), Delete empty lists, SetData empty data, GetIntegerv
|
||||
size zero, etc.). Add guards or documented errors. go vet reports unsafe
|
||||
pointer misuse in alcCore.go; replace stored uintptr C handles with a
|
||||
vetted representation/pattern and re-run vet. Listener orientation uses a
|
||||
global tempSlice without synchronization; use a local fixed array.
|
||||
|
||||
[x] 33. OpenAL errors are mostly ignored
|
||||
Files: gumble/gumbleopenal/stream.go, gumble/go-openal/openal/*.go
|
||||
Source/buffer/context/capture calls generally do not check AL/ALC errors,
|
||||
so invalid device/context/buffer operations become silent audio failure.
|
||||
Add checked wrapper operations for lifecycle-critical calls and surface
|
||||
actionable errors to Barnard.
|
||||
|
||||
[x] 34. Beep helpers panic when the external command is absent
|
||||
Files: ui.go, gumble/gumbleopenal/stream.go
|
||||
Both helpers panic on exec failure. Return/log an error or remove unused
|
||||
helpers; a missing optional beep binary must not terminate Barnard.
|
||||
|
||||
[x] 35. Admin manual-ban duration accepts negative values
|
||||
Files: admin.go, gumble/gumble/bans.go
|
||||
Negative minutes become a negative duration, then are cast to uint32
|
||||
seconds for the protocol, creating a huge ban duration. Reject negative
|
||||
durations and validate mask/address before sending.
|
||||
|
||||
[x] 36. Admin, tree, and client code read mutable maps outside Client.Do
|
||||
Files: admin.go, ui_tree.go, client.go
|
||||
UI code ranges Client.Users, Channels, Channel.Users, and Children while
|
||||
network handlers mutate them. This overlaps issue 2 but must be fixed on
|
||||
the UI side as well: take a safe snapshot under the client lock and render
|
||||
the snapshot outside the lock.
|
||||
|
||||
Suggested repair order
|
||||
----------------------
|
||||
|
||||
1. Remove crypto secret logging; fix ContextActions initialization/client;
|
||||
fix the handler deadlock and OpenAL Buffer.Delete.
|
||||
2. Define client/UDP/audio-stream locking and lifecycle ownership, then add
|
||||
race/integration tests for user removal, disconnect, reconnect, and UDP
|
||||
fallback.
|
||||
3. Make UI updates single-threaded and sanitize terminal output.
|
||||
4. Repair recording/file/capture session ownership and joining.
|
||||
5. Fix protocol data errors, validation, Opus interval math, and config input.
|
||||
6. Harden the OpenAL binding and remaining UI/config edge cases.
|
||||
|
||||
Required verification after fixes
|
||||
--------------------------------
|
||||
|
||||
- gofmt only touched files.
|
||||
- go test ./...
|
||||
- go test -race ./...
|
||||
- go vet ./... with no remaining unsafe-pointer warnings.
|
||||
- Add focused unit tests for every deterministic bug above.
|
||||
- Add a local Mumble integration test or reproducible harness for TCP-only,
|
||||
UDP success, blocked inbound UDP fallback, reconnect, user removal during
|
||||
UDP audio, and native 1.5 UDP reference packets.
|
||||
- Manually test capture stop/start, disconnect/reconnect, file start/stop,
|
||||
recording stop, zero mic volume, Unicode/UI input, and no-beep/no-ffmpeg
|
||||
failure paths.
|
||||
@@ -54,10 +54,12 @@ func (e *AudioListeners) Attach(listener AudioListener) Detacher {
|
||||
if e.head == nil {
|
||||
e.head = item
|
||||
}
|
||||
if e.tail == nil {
|
||||
e.tail = item
|
||||
} else {
|
||||
if e.tail != nil {
|
||||
e.tail.next = item
|
||||
}
|
||||
// tail was previously left pointing at the first item ever attached. Once
|
||||
// anything detached, the next attach linked itself onto a node that was no
|
||||
// longer in the list, so that listener never received audio again.
|
||||
e.tail = item
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package gumble
|
||||
|
||||
import "testing"
|
||||
|
||||
type countingAudioListener struct{ streams int }
|
||||
|
||||
func (l *countingAudioListener) OnAudioStream(e *AudioStreamEvent) {
|
||||
l.streams++
|
||||
go func() {
|
||||
for range e.C {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Regression: an audio listener is removed from the shared list only by
|
||||
// Detach. A stream that was created but never destroyed therefore stayed
|
||||
// subscribed for the life of the process, and every audio packet from every
|
||||
// user was dispatched to it as well — one goroutine, one packet queue and one
|
||||
// set of playback buffers per orphan, per user. This test pins the fan-out
|
||||
// behaviour that makes failing to detach so expensive.
|
||||
func TestDispatchAudioFansOutToEveryAttachedListener(t *testing.T) {
|
||||
c := &Client{Config: NewConfig(), Users: make(Users)}
|
||||
user := c.Users.create(1)
|
||||
|
||||
first := &countingAudioListener{}
|
||||
second := &countingAudioListener{}
|
||||
firstLink := c.Config.AttachAudio(first)
|
||||
c.Config.AttachAudio(second)
|
||||
|
||||
c.dispatchAudio(user, &AudioPacket{Client: c, Sender: user})
|
||||
if first.streams != 1 || second.streams != 1 {
|
||||
t.Fatalf("expected both listeners to receive the stream, got %d and %d",
|
||||
first.streams, second.streams)
|
||||
}
|
||||
|
||||
// Detaching must actually stop the fan-out; this is the only thing that
|
||||
// keeps a replaced stream from accumulating.
|
||||
firstLink.Detach()
|
||||
third := &countingAudioListener{}
|
||||
c.Config.AttachAudio(third)
|
||||
other := c.Users.create(2)
|
||||
c.dispatchAudio(other, &AudioPacket{Client: c, Sender: other})
|
||||
|
||||
if first.streams != 1 {
|
||||
t.Fatalf("detached listener still received audio: %d streams", first.streams)
|
||||
}
|
||||
if second.streams != 2 || third.streams != 1 {
|
||||
t.Fatalf("attached listeners missed the second user: %d and %d",
|
||||
second.streams, third.streams)
|
||||
}
|
||||
}
|
||||
|
||||
// Detach must also release the per-user stream channels it owns, so a
|
||||
// destroyed stream does not pin its queued audio.
|
||||
func TestDetachClosesPerUserStreams(t *testing.T) {
|
||||
c := &Client{Config: NewConfig(), Users: make(Users)}
|
||||
user := c.Users.create(1)
|
||||
|
||||
listener := &countingAudioListener{}
|
||||
link := c.Config.AttachAudio(listener)
|
||||
c.dispatchAudio(user, &AudioPacket{Client: c, Sender: user})
|
||||
|
||||
item := c.Config.AudioListeners.head
|
||||
if item == nil || len(item.streams) != 1 {
|
||||
t.Fatal("expected one per-user stream before detaching")
|
||||
}
|
||||
link.Detach()
|
||||
if len(item.streams) != 0 {
|
||||
t.Fatalf("Detach left %d per-user streams behind", len(item.streams))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package gumble
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/barnard/gumble/gumble/MumbleProto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Regression: handleChannelState accepted any parent the server named, so a
|
||||
// channel could be made its own ancestor. Everything that walks the resulting
|
||||
// Parent/Children graph then recurses until it exhausts memory.
|
||||
func TestChannelStateRejectsSelfParent(t *testing.T) {
|
||||
c := &Client{Config: NewConfig(), Channels: make(Channels)}
|
||||
root := c.Channels.create(0)
|
||||
child := c.Channels.create(1)
|
||||
child.Parent = root
|
||||
root.Children[child.ID] = child
|
||||
|
||||
id, parent := child.ID, child.ID
|
||||
data, _ := proto.Marshal(&MumbleProto.ChannelState{ChannelId: &id, Parent: &parent})
|
||||
if err := c.handleChannelState(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if child.Parent == child {
|
||||
t.Fatal("channel was made its own parent")
|
||||
}
|
||||
if _, ok := child.Children[child.ID]; ok {
|
||||
t.Fatal("channel was made its own child")
|
||||
}
|
||||
if child.Parent != root {
|
||||
t.Fatal("the rejected move should have left the original parent intact")
|
||||
}
|
||||
}
|
||||
|
||||
// A channel must not be reparented under one of its own descendants either.
|
||||
func TestChannelStateRejectsDescendantParent(t *testing.T) {
|
||||
c := &Client{Config: NewConfig(), Channels: make(Channels)}
|
||||
root := c.Channels.create(0)
|
||||
middle := c.Channels.create(1)
|
||||
leaf := c.Channels.create(2)
|
||||
middle.Parent, root.Children[middle.ID] = root, middle
|
||||
leaf.Parent, middle.Children[leaf.ID] = middle, leaf
|
||||
|
||||
id, parent := middle.ID, leaf.ID
|
||||
data, _ := proto.Marshal(&MumbleProto.ChannelState{ChannelId: &id, Parent: &parent})
|
||||
if err := c.handleChannelState(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if middle.Parent != root {
|
||||
t.Fatal("a cyclic reparent was applied instead of ignored")
|
||||
}
|
||||
if isChannelDescendant(middle.Parent, middle) {
|
||||
t.Fatal("channel graph is cyclic")
|
||||
}
|
||||
}
|
||||
|
||||
// A legitimate move must still be applied.
|
||||
func TestChannelStateAllowsNonCyclicMove(t *testing.T) {
|
||||
c := &Client{Config: NewConfig(), Channels: make(Channels)}
|
||||
root := c.Channels.create(0)
|
||||
a := c.Channels.create(1)
|
||||
b := c.Channels.create(2)
|
||||
a.Parent, root.Children[a.ID] = root, a
|
||||
b.Parent, root.Children[b.ID] = root, b
|
||||
|
||||
id, parent := b.ID, a.ID
|
||||
data, _ := proto.Marshal(&MumbleProto.ChannelState{ChannelId: &id, Parent: &parent})
|
||||
if err := c.handleChannelState(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if b.Parent != a {
|
||||
t.Fatal("a valid reparent was rejected")
|
||||
}
|
||||
if a.Children[b.ID] != b {
|
||||
t.Fatal("child link missing after a valid reparent")
|
||||
}
|
||||
if _, ok := root.Children[b.ID]; ok {
|
||||
t.Fatal("stale child link left on the old parent")
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,12 @@ func DialWithDialer(dialer *net.Dialer, config *Config, tlsConfig *tls.Config) (
|
||||
|
||||
state: uint32(StateConnected),
|
||||
|
||||
connect: make(chan *RejectError),
|
||||
// Buffered: once DialWithDialer returns on its synchronization
|
||||
// timeout nothing reads this channel again, and an unbuffered send
|
||||
// from handleReject would block readRoutine forever, leaking the
|
||||
// goroutine along with the client, its user and channel maps and its
|
||||
// multi-megabyte read buffer.
|
||||
connect: make(chan *RejectError, 1),
|
||||
end: make(chan struct{}),
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@ type Config struct {
|
||||
Buffers int
|
||||
}
|
||||
|
||||
// MaximumBuffers caps Config.Buffers. Each buffer holds up to one maximum
|
||||
// sized audio frame and is allocated per speaking user, so a large value
|
||||
// multiplied by a populated channel is a substantial amount of memory. A few
|
||||
// seconds of buffering is already far more than playback needs.
|
||||
const MaximumBuffers = 1024
|
||||
|
||||
// NewConfig returns a new Config struct with default values set.
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
@@ -64,6 +70,12 @@ func (c *Config) Validate() error {
|
||||
if c.Buffers <= 0 {
|
||||
return fmt.Errorf("gumble: Buffers must be positive")
|
||||
}
|
||||
// Buffers is allocated per speaking user, both as a queue of decoded
|
||||
// frames and as OpenAL playback buffers, so an unbounded value multiplies
|
||||
// straight into memory use as a channel fills up.
|
||||
if c.Buffers > MaximumBuffers {
|
||||
return fmt.Errorf("gumble: Buffers must be at most %d", MaximumBuffers)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package gumble
|
||||
|
||||
import "testing"
|
||||
|
||||
// Regression: Buffers had no upper bound, but it is allocated per speaking
|
||||
// user both as a decoded-frame queue and as OpenAL playback buffers.
|
||||
func TestConfigRejectsOversizedBuffers(t *testing.T) {
|
||||
config := NewConfig()
|
||||
config.Buffers = MaximumBuffers + 1
|
||||
if err := config.Validate(); err == nil {
|
||||
t.Fatal("expected Buffers above the maximum to be rejected")
|
||||
}
|
||||
config.Buffers = MaximumBuffers
|
||||
if err := config.Validate(); err != nil {
|
||||
t.Fatalf("Buffers at the maximum should be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import (
|
||||
// DefaultPort is the default port on which Mumble servers listen.
|
||||
const DefaultPort = 64738
|
||||
|
||||
// retainedPacketBytes is the largest read buffer kept between packets. Bigger
|
||||
// buffers are allocated as needed and released again afterwards.
|
||||
const retainedPacketBytes = 64 * 1024
|
||||
|
||||
// Conn represents a control protocol connection to a Mumble client/server.
|
||||
type Conn struct {
|
||||
sync.Mutex
|
||||
@@ -54,6 +58,11 @@ func (c *Conn) ReadPacket() (uint16, []byte, error) {
|
||||
}
|
||||
if pLengthInt > len(c.buffer) {
|
||||
c.buffer = make([]byte, pLengthInt)
|
||||
} else if len(c.buffer) > retainedPacketBytes && pLengthInt <= retainedPacketBytes {
|
||||
// One oversized packet — a large ACL, user list or channel comment —
|
||||
// used to pin its full size for the life of the connection. Give the
|
||||
// memory back once ordinary traffic resumes.
|
||||
c.buffer = make([]byte, retainedPacketBytes)
|
||||
}
|
||||
if _, err := io.ReadFull(c.Conn, c.buffer[:pLengthInt]); err != nil {
|
||||
return 0, nil, err
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package gumble
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Regression: the read buffer grew to the largest packet ever seen and kept
|
||||
// that memory for the life of the connection.
|
||||
func TestConnBufferShrinksAfterAnOversizedPacket(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
conn := NewConn(client)
|
||||
|
||||
big := 4 * 1024 * 1024
|
||||
go func() {
|
||||
defer server.Close()
|
||||
writePacket := func(length int) {
|
||||
var header [6]byte
|
||||
binary.BigEndian.PutUint16(header[:], 3)
|
||||
binary.BigEndian.PutUint32(header[2:], uint32(length))
|
||||
server.Write(header[:])
|
||||
server.Write(make([]byte, length))
|
||||
}
|
||||
writePacket(big)
|
||||
writePacket(128)
|
||||
}()
|
||||
|
||||
if _, _, err := conn.ReadPacket(); err != nil {
|
||||
t.Fatalf("reading the oversized packet: %v", err)
|
||||
}
|
||||
if len(conn.buffer) < big {
|
||||
t.Fatalf("oversized packet should have grown the buffer, got %d", len(conn.buffer))
|
||||
}
|
||||
if _, _, err := conn.ReadPacket(); err != nil {
|
||||
t.Fatalf("reading the small packet: %v", err)
|
||||
}
|
||||
if len(conn.buffer) > retainedPacketBytes {
|
||||
t.Fatalf("buffer stayed at %d bytes after a small packet, above the %d retained size",
|
||||
len(conn.buffer), retainedPacketBytes)
|
||||
}
|
||||
}
|
||||
+39
-11
@@ -184,8 +184,10 @@ func (c *Client) handleUDPTunnel(buffer []byte) error {
|
||||
log.Info("handleUDPTunnel: %s session=%d seq=%d audio_len=%d final=%v buf_remain=%d",
|
||||
user.Name, session, seq, audioLength, isFinal, len(buffer))
|
||||
|
||||
if audioLength > len(buffer) {
|
||||
log.Warn("handleUDPTunnel: audio length %d > remaining buffer %d",
|
||||
// A negative length would pass the upper bound check below and then panic
|
||||
// on the slice expression.
|
||||
if audioLength < 0 || audioLength > len(buffer) {
|
||||
log.Warn("handleUDPTunnel: audio length %d out of range for buffer %d",
|
||||
audioLength, len(buffer))
|
||||
return errInvalidProtobuf
|
||||
}
|
||||
@@ -447,6 +449,23 @@ func (c *Client) handleChannelRemove(buffer []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxChannelDepth bounds ancestry walks over a channel tree that may already
|
||||
// be cyclic. Real Mumble trees are far shallower than this.
|
||||
const maxChannelDepth = 1024
|
||||
|
||||
// isChannelDescendant reports whether candidate is channel itself or sits
|
||||
// below it in the channel tree. The walk is bounded so an already-cyclic
|
||||
// graph cannot hang the caller.
|
||||
func isChannelDescendant(candidate, channel *Channel) bool {
|
||||
for i := 0; candidate != nil && i <= maxChannelDepth; i++ {
|
||||
if candidate == channel {
|
||||
return true
|
||||
}
|
||||
candidate = candidate.Parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Client) handleChannelState(buffer []byte) error {
|
||||
var packet MumbleProto.ChannelState
|
||||
if err := proto.Unmarshal(buffer, &packet); err != nil {
|
||||
@@ -473,16 +492,25 @@ func (c *Client) handleChannelState(buffer []byte) error {
|
||||
}
|
||||
event.Channel = channel
|
||||
if packet.Parent != nil {
|
||||
if channel.Parent != nil {
|
||||
delete(channel.Parent.Children, channelID)
|
||||
}
|
||||
newParent := c.Channels[*packet.Parent]
|
||||
if newParent != channel.Parent {
|
||||
event.Type |= ChannelChangeMoved
|
||||
}
|
||||
channel.Parent = newParent
|
||||
if channel.Parent != nil {
|
||||
channel.Parent.Children[channel.ID] = channel
|
||||
// Reparenting a channel under itself or one of its own
|
||||
// descendants makes Parent/Children cyclic, and anything that
|
||||
// walks the tree then recurses until it exhausts memory. Ignore
|
||||
// the move rather than corrupt the channel graph.
|
||||
if isChannelDescendant(newParent, channel) {
|
||||
log.Warn("handleChannelState: ignoring cyclic parent %d for channel %d",
|
||||
*packet.Parent, channelID)
|
||||
} else {
|
||||
if channel.Parent != nil {
|
||||
delete(channel.Parent.Children, channelID)
|
||||
}
|
||||
if newParent != channel.Parent {
|
||||
event.Type |= ChannelChangeMoved
|
||||
}
|
||||
channel.Parent = newParent
|
||||
if channel.Parent != nil {
|
||||
channel.Parent.Children[channel.ID] = channel
|
||||
}
|
||||
}
|
||||
}
|
||||
if packet.Name != nil {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package gumble
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/barnard/gumble/gumble/MumbleProto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Regression: the connect channel was unbuffered, so a Reject arriving after
|
||||
// DialWithDialer had already returned on its synchronization timeout blocked
|
||||
// readRoutine forever, leaking that goroutine and the whole client with it.
|
||||
func TestHandleRejectDoesNotBlockWithoutAReceiver(t *testing.T) {
|
||||
c := &Client{
|
||||
Config: NewConfig(),
|
||||
Users: make(Users),
|
||||
connect: make(chan *RejectError, 1),
|
||||
state: uint32(StateConnected),
|
||||
}
|
||||
c.Conn = NewConn(nopConn{})
|
||||
|
||||
reason := "server is full"
|
||||
data, _ := proto.Marshal(&MumbleProto.Reject{Reason: &reason})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = c.handleReject(data)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-timeoutChan():
|
||||
t.Fatal("handleReject blocked with no reader on the connect channel")
|
||||
}
|
||||
}
|
||||
|
||||
// nopConn is a net.Conn that discards everything, so handleReject's Close call
|
||||
// has something to act on.
|
||||
type nopConn struct{}
|
||||
|
||||
func (nopConn) Read(b []byte) (int, error) { return 0, io.EOF }
|
||||
func (nopConn) Write(b []byte) (int, error) { return len(b), nil }
|
||||
func (nopConn) Close() error { return nil }
|
||||
func (nopConn) LocalAddr() net.Addr { return nil }
|
||||
func (nopConn) RemoteAddr() net.Addr { return nil }
|
||||
func (nopConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (nopConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (nopConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
func timeoutChan() <-chan time.Time {
|
||||
return time.After(10 * time.Second)
|
||||
}
|
||||
@@ -138,6 +138,10 @@ type Stream struct {
|
||||
recorderMu sync.RWMutex
|
||||
errorFunc func(error) // called on capture errors
|
||||
recorder Recorder
|
||||
// streamWG tracks the per-user goroutines started by OnAudioStream. They
|
||||
// release their OpenAL source and buffers through the renderer, so Destroy
|
||||
// must let them finish before it tears the renderer down.
|
||||
streamWG sync.WaitGroup
|
||||
}
|
||||
|
||||
func New(client *gumble.Client, inputDevice *string, outputDevice *string, test bool) (*Stream, error) {
|
||||
@@ -393,13 +397,37 @@ func (s *Stream) getRecorder() Recorder {
|
||||
return s.recorder
|
||||
}
|
||||
|
||||
// destroyDrainTimeout bounds how long Destroy waits for the per-user audio
|
||||
// goroutines to finish draining, so a wedged renderer cannot hang a reconnect.
|
||||
const destroyDrainTimeout = 2 * time.Second
|
||||
|
||||
func (s *Stream) Destroy() {
|
||||
if s.link != nil {
|
||||
// Detach closes every per-user stream channel, which ends the
|
||||
// goroutines started by OnAudioStream.
|
||||
s.link.Detach()
|
||||
}
|
||||
// Those goroutines delete their OpenAL source and buffers through
|
||||
// s.render, which stops working the moment the renderer is closed below.
|
||||
// Waiting for them here is what keeps the device's sources and buffers
|
||||
// from being orphaned on every reconnect.
|
||||
drained := make(chan struct{})
|
||||
go func() {
|
||||
s.streamWG.Wait()
|
||||
close(drained)
|
||||
}()
|
||||
select {
|
||||
case <-drained:
|
||||
case <-time.After(destroyDrainTimeout):
|
||||
log.Warn("Destroy: timed out waiting for audio stream goroutines; " +
|
||||
"OpenAL sources and buffers may be released only by CloseDevice")
|
||||
}
|
||||
if s.deviceSource != nil {
|
||||
s.StopSource()
|
||||
s.deviceSource.CaptureCloseDevice()
|
||||
if !s.deviceSource.CaptureCloseDevice() {
|
||||
log.Error("Destroy: closing capture device %q failed",
|
||||
deviceName(s.inputDeviceName))
|
||||
}
|
||||
s.deviceSource = nil
|
||||
}
|
||||
if s.deviceSink != nil {
|
||||
@@ -417,7 +445,14 @@ func (s *Stream) Destroy() {
|
||||
<-s.renderDone
|
||||
s.contextSink = nil
|
||||
}
|
||||
s.deviceSink.CloseDevice()
|
||||
// alcCloseDevice returns ALC_FALSE and frees nothing while the device
|
||||
// still has contexts, buffers or sources outstanding. Dropping the
|
||||
// handle after that silently leaks the device and every buffer it
|
||||
// owns, which is invisible to the Go GC, so at least report it.
|
||||
if !s.deviceSink.CloseDevice() {
|
||||
log.Error("Destroy: closing playback device %q failed; its OpenAL "+
|
||||
"buffers cannot be reclaimed", deviceName(s.outputDeviceName))
|
||||
}
|
||||
s.deviceSink = nil
|
||||
}
|
||||
}
|
||||
@@ -485,7 +520,9 @@ func (s *Stream) SetMicVolume(change float32, relative bool) {
|
||||
}
|
||||
|
||||
func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
||||
s.streamWG.Add(1)
|
||||
go func(e *gumble.AudioStreamEvent) {
|
||||
defer s.streamWG.Done()
|
||||
log.Info("audio stream started for user %s", e.User.Name)
|
||||
var source openal.Source
|
||||
var emptyBufs openal.Buffers
|
||||
@@ -576,6 +613,9 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
||||
return nil
|
||||
}
|
||||
p := jitterBuf[0]
|
||||
// Clear the slot before resliceing: the popped entries stay in
|
||||
// the backing array otherwise, pinning a decoded frame each.
|
||||
jitterBuf[0] = nil
|
||||
jitterBuf = jitterBuf[1:]
|
||||
jitterDuration -= audioPacketDuration(p)
|
||||
// Frame numbers are Mumble timestamps in 10 ms units.
|
||||
@@ -657,6 +697,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
|
||||
jitterBuf[0].Sequence, e.User.Name, jitterNextSeq, len(jitterBuf))
|
||||
}
|
||||
jitterDuration -= audioPacketDuration(jitterBuf[0])
|
||||
jitterBuf[0] = nil
|
||||
jitterBuf = jitterBuf[1:]
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ func main() {
|
||||
if err != nil {
|
||||
handle_raw_error(err)
|
||||
}
|
||||
if *buffers <= 0 || *buffers > gumble.MaximumBuffers {
|
||||
handle_raw_error(fmt.Errorf("buffers must be between 1 and %d, got %d",
|
||||
gumble.MaximumBuffers, *buffers))
|
||||
}
|
||||
|
||||
// Set up logging
|
||||
var level barnlog.Level
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package recording
|
||||
|
||||
import "testing"
|
||||
|
||||
// Regression: the per-source mix queues grew without bound. The tick drains a
|
||||
// fixed chunk per source, so a stalled encoder leaves a deficit the loop never
|
||||
// makes up, and the backlog only ever grew from there.
|
||||
func TestRecorderQueueIsCapped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var queue []int16
|
||||
frame := make([]int16, 960)
|
||||
// Far more audio than the encoder could have consumed.
|
||||
for i := 0; i < 2000; i++ {
|
||||
queue = appendCapped(queue, frame)
|
||||
}
|
||||
|
||||
if len(queue) > maxQueuedSamples {
|
||||
t.Fatalf("queue grew to %d samples, above the %d cap",
|
||||
len(queue), maxQueuedSamples)
|
||||
}
|
||||
}
|
||||
|
||||
// Capping must keep the newest audio: dropping the newest would make the
|
||||
// recording lag further behind with every overflow.
|
||||
func TestRecorderQueueKeepsNewestAudio(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var queue []int16
|
||||
// Fill past the cap with a marker in the final frame.
|
||||
filler := make([]int16, maxQueuedSamples)
|
||||
queue = appendCapped(queue, filler)
|
||||
newest := []int16{1, 2, 3, 4}
|
||||
queue = appendCapped(queue, newest)
|
||||
|
||||
if len(queue) != maxQueuedSamples {
|
||||
t.Fatalf("expected the queue to sit at the %d cap, got %d",
|
||||
maxQueuedSamples, len(queue))
|
||||
}
|
||||
tail := queue[len(queue)-len(newest):]
|
||||
for i, want := range newest {
|
||||
if tail[i] != want {
|
||||
t.Fatalf("newest audio was dropped: tail %v, want %v", tail, newest)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -20,6 +20,12 @@ const (
|
||||
FormatOpus = "opus"
|
||||
)
|
||||
|
||||
// maxQueuedSamples bounds the per-source mix backlog at roughly five seconds
|
||||
// of 48 kHz stereo audio. A source that runs further ahead than this is ahead
|
||||
// because the encoder stalled, and no amount of retained audio recovers the
|
||||
// timeline; keeping the newest is better than growing without bound.
|
||||
const maxQueuedSamples = 5 * gumble.AudioSampleRate * gumble.AudioChannels
|
||||
|
||||
type Recorder struct {
|
||||
path string
|
||||
format string
|
||||
@@ -188,6 +194,19 @@ func (r *Recorder) Stop() error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
// appendCapped adds a source's incoming samples to its mix queue, bounded at
|
||||
// maxQueuedSamples. Each tick drains one fixed chunk per source, so a stalled
|
||||
// encoder leaves a deficit the loop never makes up and the backlog would
|
||||
// otherwise grow for as long as the recording ran. The newest audio is kept:
|
||||
// discarding it instead would only push the recording further behind.
|
||||
func appendCapped(queue []int16, incoming []int16) []int16 {
|
||||
queue = append(queue, incoming...)
|
||||
if len(queue) > maxQueuedSamples {
|
||||
queue = append(queue[:0], queue[len(queue)-maxQueuedSamples:]...)
|
||||
}
|
||||
return queue
|
||||
}
|
||||
|
||||
func (r *Recorder) run() {
|
||||
defer close(r.done)
|
||||
ticker := time.NewTicker(r.interval)
|
||||
@@ -203,7 +222,7 @@ func (r *Recorder) run() {
|
||||
r.closeEncoder()
|
||||
return
|
||||
case item := <-r.input:
|
||||
queues[item.source] = append(queues[item.source], item.samples...)
|
||||
queues[item.source] = appendCapped(queues[item.source], item.samples)
|
||||
case <-ticker.C:
|
||||
clear(chunk)
|
||||
for source, buffer := range queues {
|
||||
|
||||
@@ -445,15 +445,19 @@ func (b *Barnard) OnMicVolumeUp(ui *uiterm.Ui, key uiterm.Key) {
|
||||
}
|
||||
|
||||
func (b *Barnard) OnQuitPress(ui *uiterm.Ui, key uiterm.Key) {
|
||||
b.stopReconnects()
|
||||
b.StopRecordingIfActive(true)
|
||||
b.Client.Disconnect()
|
||||
b.Ui.Close()
|
||||
b.shutdown()
|
||||
}
|
||||
|
||||
func (b *Barnard) CommandExit(ui *uiterm.Ui, cmd string) {
|
||||
b.shutdown()
|
||||
}
|
||||
|
||||
// shutdown releases everything that outlives a single connection, including
|
||||
// the tone test saver's output file, which reconnects deliberately keep open.
|
||||
func (b *Barnard) shutdown() {
|
||||
b.stopReconnects()
|
||||
b.StopRecordingIfActive(true)
|
||||
b.cleanupToneTestAudio()
|
||||
b.Client.Disconnect()
|
||||
b.Ui.Close()
|
||||
}
|
||||
|
||||
+56
-28
@@ -72,6 +72,48 @@ func (t *Textview) ScrollBottom() {
|
||||
t.uiDraw()
|
||||
}
|
||||
|
||||
const (
|
||||
// maxScrollbackLines bounds the retained chat history. It used to grow for
|
||||
// the life of the process, and every line added re-wrapped the whole
|
||||
// buffer, so the cost of a session grew as the square of its length. This
|
||||
// is far more history than a reader ever scrolls back through.
|
||||
maxScrollbackLines = 10000
|
||||
// scrollbackTrimChunk is how much history is discarded once the cap is
|
||||
// reached. Trimming a block at a time means the rebuild it forces happens
|
||||
// once every scrollbackTrimChunk lines rather than on every line, which
|
||||
// keeps the amortised cost of an append constant.
|
||||
scrollbackTrimChunk = 1000
|
||||
)
|
||||
|
||||
// wrapLine renders one stored line as the display lines it occupies.
|
||||
func (t *Textview) wrapLine(line string, width int) []string {
|
||||
l := line
|
||||
if !t.showTimestamps {
|
||||
// Server and local messages need not have a timestamp prefix.
|
||||
if _, text, ok := strings.Cut(line, "]"); ok {
|
||||
l = strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
var wrapped []string
|
||||
// A Builder keeps this linear; appending a rune at a time to a string
|
||||
// reallocates once per character.
|
||||
var current strings.Builder
|
||||
chars := 0
|
||||
for _, ch := range l {
|
||||
if chars >= width {
|
||||
wrapped = append(wrapped, current.String())
|
||||
current.Reset()
|
||||
chars = 0
|
||||
}
|
||||
current.WriteRune(ch)
|
||||
chars++
|
||||
}
|
||||
if chars > 0 {
|
||||
wrapped = append(wrapped, current.String())
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
func (t *Textview) updateParsedLines() {
|
||||
width := t.x1 - t.x0
|
||||
|
||||
@@ -83,33 +125,7 @@ func (t *Textview) updateParsedLines() {
|
||||
|
||||
parsed := make([]string, 0, len(t.Lines))
|
||||
for _, line := range t.Lines {
|
||||
var l = line
|
||||
if !t.showTimestamps {
|
||||
// Server and local messages need not have a timestamp prefix.
|
||||
if _, text, ok := strings.Cut(line, "]"); ok {
|
||||
l = strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
current := ""
|
||||
chars := 0
|
||||
reader := strings.NewReader(l)
|
||||
for {
|
||||
if chars >= width {
|
||||
parsed = append(parsed, current)
|
||||
chars = 0
|
||||
current = ""
|
||||
}
|
||||
if reader.Len() <= 0 {
|
||||
if chars > 0 {
|
||||
parsed = append(parsed, current)
|
||||
}
|
||||
break
|
||||
}
|
||||
if ch, _, err := reader.ReadRune(); err == nil {
|
||||
current = current + string(ch)
|
||||
chars++
|
||||
}
|
||||
}
|
||||
parsed = append(parsed, t.wrapLine(line, width)...)
|
||||
}
|
||||
t.parsedLines = parsed
|
||||
t.clampCurrentLine()
|
||||
@@ -117,7 +133,19 @@ func (t *Textview) updateParsedLines() {
|
||||
|
||||
func (t *Textview) AddLine(line string) {
|
||||
t.Lines = append(t.Lines, line)
|
||||
t.updateParsedLines()
|
||||
if len(t.Lines) > maxScrollbackLines {
|
||||
// Trimming invalidates the wrapped buffer and forces a rebuild, so
|
||||
// discard a block rather than a single line; otherwise every append
|
||||
// past the cap would re-wrap the whole buffer.
|
||||
keep := maxScrollbackLines - scrollbackTrimChunk
|
||||
t.Lines = append(t.Lines[:0], t.Lines[len(t.Lines)-keep:]...)
|
||||
t.updateParsedLines()
|
||||
} else if width := t.x1 - t.x0; width > 0 {
|
||||
// Wrap just the new line. Rebuilding every stored line on each append
|
||||
// is what made a long-lived session stall the terminal.
|
||||
t.parsedLines = append(t.parsedLines, t.wrapLine(line, width)...)
|
||||
t.clampCurrentLine()
|
||||
}
|
||||
t.uiDraw()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package uiterm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// addLine appends without drawing, so these tests need no terminal.
|
||||
func addLineNoDraw(t *Textview, line string) {
|
||||
t.Lines = append(t.Lines, line)
|
||||
if len(t.Lines) > maxScrollbackLines {
|
||||
keep := maxScrollbackLines - scrollbackTrimChunk
|
||||
t.Lines = append(t.Lines[:0], t.Lines[len(t.Lines)-keep:]...)
|
||||
t.updateParsedLines()
|
||||
return
|
||||
}
|
||||
if width := t.x1 - t.x0; width > 0 {
|
||||
t.parsedLines = append(t.parsedLines, t.wrapLine(line, width)...)
|
||||
t.clampCurrentLine()
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: AddLine used to re-wrap every stored line on each append, which
|
||||
// made the cost of a session grow as the square of its length. It now wraps
|
||||
// only the new line, so that incremental result must match a full rebuild.
|
||||
func TestTextviewIncrementalWrapMatchesFullRebuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lines := []string{
|
||||
"short [12:00:01]",
|
||||
strings.Repeat("a", 200) + " [12:00:02]",
|
||||
"",
|
||||
"exactly-twenty-chars",
|
||||
"unicode ünïcödé line with wide content [12:00:03]",
|
||||
}
|
||||
|
||||
incremental := &Textview{x0: 0, x1: 20, showTimestamps: true}
|
||||
for _, line := range lines {
|
||||
addLineNoDraw(incremental, line)
|
||||
}
|
||||
|
||||
full := &Textview{x0: 0, x1: 20, showTimestamps: true}
|
||||
full.Lines = append([]string(nil), lines...)
|
||||
full.updateParsedLines()
|
||||
|
||||
if len(incremental.parsedLines) != len(full.parsedLines) {
|
||||
t.Fatalf("incremental produced %d wrapped lines, full rebuild %d",
|
||||
len(incremental.parsedLines), len(full.parsedLines))
|
||||
}
|
||||
for i := range full.parsedLines {
|
||||
if incremental.parsedLines[i] != full.parsedLines[i] {
|
||||
t.Fatalf("wrapped line %d differs: incremental %q, full %q",
|
||||
i, incremental.parsedLines[i], full.parsedLines[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the scrollback had no cap, so a long-lived client retained every
|
||||
// line it had ever displayed.
|
||||
func TestTextviewScrollbackIsCapped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := &Textview{x0: 0, x1: 40, showTimestamps: true}
|
||||
for i := 0; i < maxScrollbackLines+500; i++ {
|
||||
addLineNoDraw(view, fmt.Sprintf("line %d", i))
|
||||
}
|
||||
|
||||
if len(view.Lines) > maxScrollbackLines {
|
||||
t.Fatalf("expected scrollback capped at %d lines, got %d",
|
||||
maxScrollbackLines, len(view.Lines))
|
||||
}
|
||||
if len(view.Lines) < maxScrollbackLines-scrollbackTrimChunk {
|
||||
t.Fatalf("trim discarded more than one chunk: %d lines remain", len(view.Lines))
|
||||
}
|
||||
// The newest line must survive; the oldest must not.
|
||||
if got := view.Lines[len(view.Lines)-1]; got != fmt.Sprintf("line %d", maxScrollbackLines+499) {
|
||||
t.Fatalf("newest line was dropped, got %q", got)
|
||||
}
|
||||
if view.Lines[0] == "line 0" {
|
||||
t.Fatal("oldest line should have been trimmed")
|
||||
}
|
||||
if len(view.parsedLines) != len(view.Lines) {
|
||||
t.Fatalf("wrapped buffer out of sync after trim: %d wrapped, %d stored",
|
||||
len(view.parsedLines), len(view.Lines))
|
||||
}
|
||||
}
|
||||
|
||||
// wrapLine replaced a loop that concatenated one rune at a time; confirm the
|
||||
// wrapping itself is unchanged for the boundary cases.
|
||||
func TestTextviewWrapLineBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := &Textview{showTimestamps: true}
|
||||
for _, tc := range []struct {
|
||||
line string
|
||||
width int
|
||||
want []string
|
||||
}{
|
||||
{"", 5, nil},
|
||||
{"abc", 5, []string{"abc"}},
|
||||
{"abcde", 5, []string{"abcde"}},
|
||||
{"abcdef", 5, []string{"abcde", "f"}},
|
||||
{"abcdeabcde", 5, []string{"abcde", "abcde"}},
|
||||
} {
|
||||
got := view.wrapLine(tc.line, tc.width)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("wrapLine(%q, %d) = %q, want %q", tc.line, tc.width, got, tc.want)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("wrapLine(%q, %d) = %q, want %q", tc.line, tc.width, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-15
@@ -89,10 +89,10 @@ func (t *Tree) rebuild(preserveActive bool, sameItem func(previous, current Tree
|
||||
previousLine := t.activeLine
|
||||
lines := []renderedTreeItem{}
|
||||
for _, item := range t.Generator(nil) {
|
||||
children := t.rebuild_rec(item, 0)
|
||||
if children != nil {
|
||||
lines = append(lines, children...)
|
||||
if len(lines) >= maxTreeLines {
|
||||
break
|
||||
}
|
||||
lines = t.rebuild_rec(lines, item, 0)
|
||||
}
|
||||
t.lines = lines
|
||||
if preserveActive {
|
||||
@@ -113,21 +113,31 @@ func (t *Tree) rebuild(preserveActive bool, sameItem func(previous, current Tree
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tree) rebuild_rec(parent TreeItem, level int) []renderedTreeItem {
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
lines := []renderedTreeItem{
|
||||
renderedTreeItem{
|
||||
Level: level,
|
||||
Item: parent,
|
||||
},
|
||||
// A server is free to describe a channel graph in which a channel is its own
|
||||
// ancestor. The generator follows parent/child links literally, so without
|
||||
// these limits such a graph recurses until the process is out of memory.
|
||||
// Real trees are orders of magnitude smaller than either bound.
|
||||
const (
|
||||
maxTreeDepth = 64
|
||||
maxTreeLines = 100000
|
||||
)
|
||||
|
||||
// rebuild_rec appends parent and its descendants to lines. Accumulating into
|
||||
// one slice keeps maxTreeLines a budget for the whole tree rather than for
|
||||
// each level, and avoids building a slice per node.
|
||||
func (t *Tree) rebuild_rec(lines []renderedTreeItem, parent TreeItem, level int) []renderedTreeItem {
|
||||
if parent == nil || level >= maxTreeDepth || len(lines) >= maxTreeLines {
|
||||
return lines
|
||||
}
|
||||
lines = append(lines, renderedTreeItem{
|
||||
Level: level,
|
||||
Item: parent,
|
||||
})
|
||||
for _, item := range t.Generator(parent) {
|
||||
children := t.rebuild_rec(item, level+1)
|
||||
if children != nil {
|
||||
lines = append(lines, children...)
|
||||
if len(lines) >= maxTreeLines {
|
||||
break
|
||||
}
|
||||
lines = t.rebuild_rec(lines, item, level+1)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package uiterm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cyclicItem reports itself as its own child, standing in for a channel graph
|
||||
// in which a channel is its own ancestor.
|
||||
type cyclicItem struct{ name string }
|
||||
|
||||
func (i *cyclicItem) String() string { return i.name }
|
||||
|
||||
func (i *cyclicItem) TreeItemStyle(fg, bg Attribute, active bool) (Attribute, Attribute) {
|
||||
return fg, bg
|
||||
}
|
||||
|
||||
// Regression: rebuild_rec followed parent/child links with no depth limit, so
|
||||
// a cyclic channel graph recursed until the process ran out of memory. A
|
||||
// rebuild must now terminate and stay bounded.
|
||||
func TestTreeRebuildTerminatesOnCyclicGraph(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
self := &cyclicItem{name: "loop"}
|
||||
tree := Tree{
|
||||
Generator: func(item TreeItem) []TreeItem {
|
||||
return []TreeItem{self}
|
||||
},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
tree.rebuild(false, nil)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-timeoutAfterSeconds(10):
|
||||
t.Fatal("rebuild did not terminate on a cyclic tree")
|
||||
}
|
||||
|
||||
if len(tree.lines) == 0 {
|
||||
t.Fatal("expected the bounded rebuild to still produce lines")
|
||||
}
|
||||
if len(tree.lines) > maxTreeLines {
|
||||
t.Fatalf("rebuild produced %d lines, above the %d cap",
|
||||
len(tree.lines), maxTreeLines)
|
||||
}
|
||||
for _, line := range tree.lines {
|
||||
if line.Level >= maxTreeDepth {
|
||||
t.Fatalf("rebuild recursed to level %d, at or past the %d cap",
|
||||
line.Level, maxTreeDepth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func timeoutAfterSeconds(n int) <-chan struct{} {
|
||||
ch := make(chan struct{})
|
||||
go func() {
|
||||
time.Sleep(time.Duration(n) * time.Second)
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
Reference in New Issue
Block a user