Skip to content

Commit 6dcd42c

Browse files
mcuelenaereclaude
andcommitted
vnc: fix deadlock + linter + bugbot review issues
Three issues from Cursor Bugbot's review on commit 1d41410, plus the golangci-lint failures from the same CI run. 1. Deadlock in writeUpdate (HIGH severity). Conn.LockWrite acquired writeMu, then Conn.Flush at the end re-acquired the same non-reentrant mutex. Every VNC client hung on its first FramebufferUpdate; this is most likely the actual cause of the placeholder-forever symptom seen on-device. The intended pattern was a multi-call composite write with a private flushLocked variant — but in practice we have a strict single-writer model: serveClient drives the handshake on its own goroutine, then the dispatcher goroutine is the only writer afterward. The reader goroutine never writes. So the mutex adds no safety, only a deadlock. Strip writeMu, LockWrite, UnlockWrite, and flushLocked. Document the single-writer requirement on Conn. 2. SetEncodings allocated on the wire-supplied count (LOW severity). make([]EncodingType, count) was allocating up to 65 535 * 4 bytes regardless of the maxEncodings cap (256). Allocate based on the capped n; the loop still drains the full count from the wire so framing for the next message stays aligned. 3. Save button hidden when VNC was disabled in the UI (MEDIUM severity). The button lived inside the {settings.enabled && (...)} block, so unchecking the toggle hid the button — the disabled state could not be persisted. Move the button outside the conditional. Linter follow-ups (golangci-lint v2.4 in CI): - goimports re-aligned const blocks in keysym.go and placeholder.go. - Removed the unused acquireVideoStream wrapper now that all callers pass an explicit codec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ccc66b3 commit 6dcd42c

11 files changed

Lines changed: 112 additions & 149 deletions

File tree

internal/rfb/conn.go

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,21 @@ import (
55
"encoding/binary"
66
"io"
77
"net"
8-
"sync"
98
"time"
109
)
1110

1211
// Conn wraps a net.Conn with framing buffers for the RFB protocol.
1312
//
14-
// Reads are not safe to issue from multiple goroutines, but writes are
15-
// serialized internally. The typical pattern is one goroutine reading
16-
// client messages and one writing server messages; the read side never
17-
// writes (except via the dedicated handshake helpers, which run before
18-
// either goroutine starts).
13+
// The Conn assumes a single-writer model: at most one goroutine
14+
// invokes write methods on the same Conn at a time. In typical use
15+
// the handshake runs sequentially on one goroutine and is then
16+
// followed by a per-connection dispatcher (also single goroutine)
17+
// while a separate reader goroutine only calls read methods. Do not
18+
// share writes between goroutines without external synchronization.
1919
type Conn struct {
2020
nc net.Conn
2121
r *bufio.Reader
2222
w *bufio.Writer
23-
24-
writeMu sync.Mutex
2523
}
2624

2725
// NewConn wraps a net.Conn with read/write buffers.
@@ -45,24 +43,8 @@ func (c *Conn) SetReadDeadline(t time.Time) error { return c.nc.SetReadDeadline(
4543
// SetWriteDeadline forwards to the underlying net.Conn.
4644
func (c *Conn) SetWriteDeadline(t time.Time) error { return c.nc.SetWriteDeadline(t) }
4745

48-
// LockWrite acquires the write mutex. Callers MUST call UnlockWrite —
49-
// useful when emitting a multi-call composite message (e.g. a
50-
// FramebufferUpdate header followed by per-rectangle data).
51-
func (c *Conn) LockWrite() { c.writeMu.Lock() }
52-
53-
// UnlockWrite releases the write mutex.
54-
func (c *Conn) UnlockWrite() { c.writeMu.Unlock() }
55-
56-
// Flush flushes the write buffer to the underlying connection. It
57-
// acquires the write mutex internally.
58-
func (c *Conn) Flush() error {
59-
c.writeMu.Lock()
60-
defer c.writeMu.Unlock()
61-
return c.w.Flush()
62-
}
63-
64-
// flushLocked flushes assuming the write mutex is already held.
65-
func (c *Conn) flushLocked() error { return c.w.Flush() }
46+
// Flush flushes the write buffer to the underlying connection.
47+
func (c *Conn) Flush() error { return c.w.Flush() }
6648

6749
// readByte reads one byte.
6850
func (c *Conn) readByte() (byte, error) { return c.r.ReadByte() }

internal/rfb/encodings.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ type Rect struct {
1111
}
1212

1313
// BeginFramebufferUpdate writes the FramebufferUpdate message header
14-
// (RFC 6143 §7.6.1) for `n` rectangles. Caller MUST hold the write
15-
// mutex via Conn.LockWrite before calling and follow up with `n`
16-
// WriteRectHeader+payload pairs and then Conn.Flush+UnlockWrite.
14+
// (RFC 6143 §7.6.1) for `n` rectangles. The caller must follow up
15+
// with `n` WriteRectHeader+payload pairs and a final Conn.Flush.
16+
// The Conn type assumes a single-writer model — see Conn's doc.
1717
func (c *Conn) BeginFramebufferUpdate(n uint16) error {
1818
if err := c.writeByte(byte(ServerMsgFramebufferUpdate)); err != nil {
1919
return err

internal/rfb/encodings_test.go

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,11 @@ func TestBeginFramebufferUpdate(t *testing.T) {
1313

1414
done := make(chan error, 1)
1515
go func() {
16-
c.LockWrite()
17-
defer c.UnlockWrite()
1816
if err := c.BeginFramebufferUpdate(3); err != nil {
1917
done <- err
2018
return
2119
}
22-
done <- c.flushLocked()
20+
done <- c.Flush()
2321
}()
2422

2523
if err := cliNC.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
@@ -44,10 +42,8 @@ func TestWriteRectHeader(t *testing.T) {
4442
c := NewConn(srvNC)
4543

4644
go func() {
47-
c.LockWrite()
48-
defer c.UnlockWrite()
4945
_ = c.WriteRectHeader(Rect{X: 1, Y: 2, W: 1920, H: 1080, Encoding: EncodingOpenH264})
50-
_ = c.flushLocked()
46+
_ = c.Flush()
5147
}()
5248

5349
out := make([]byte, 12)
@@ -75,14 +71,12 @@ func TestWriteOpenH264Rect(t *testing.T) {
7571

7672
nal := []byte{0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E} // dummy NAL
7773
go func() {
78-
c.LockWrite()
79-
defer c.UnlockWrite()
8074
_ = c.WriteOpenH264Rect(
8175
Rect{X: 0, Y: 0, W: 1920, H: 1080, Encoding: EncodingOpenH264},
8276
OpenH264FlagResetContext,
8377
nal,
8478
)
85-
_ = c.flushLocked()
79+
_ = c.Flush()
8680
}()
8781

8882
out := make([]byte, 12+4+4+len(nal))
@@ -117,13 +111,11 @@ func TestWriteRawRect(t *testing.T) {
117111

118112
pixels := bytes.Repeat([]byte{0xAB}, 4*4*4) // 4x4 32bpp BGRA
119113
go func() {
120-
c.LockWrite()
121-
defer c.UnlockWrite()
122114
_ = c.WriteRawRect(
123115
Rect{X: 0, Y: 0, W: 4, H: 4, Encoding: EncodingRaw},
124116
pixels,
125117
)
126-
_ = c.flushLocked()
118+
_ = c.Flush()
127119
}()
128120

129121
out := make([]byte, 12+len(pixels))
@@ -147,10 +139,8 @@ func TestWriteDesktopSizeRect(t *testing.T) {
147139
c := NewConn(srvNC)
148140

149141
go func() {
150-
c.LockWrite()
151-
defer c.UnlockWrite()
152142
_ = c.WriteDesktopSizeRect(1920, 1080)
153-
_ = c.flushLocked()
143+
_ = c.Flush()
154144
}()
155145

156146
out := make([]byte, 12)

internal/rfb/handshake.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func (c *Conn) HandshakeServerVersion() (string, error) {
3535
if err := c.writeRaw([]byte(ProtocolVersion38)); err != nil {
3636
return "", fmt.Errorf("rfb: write server version: %w", err)
3737
}
38-
if err := c.flushLocked(); err != nil {
38+
if err := c.Flush(); err != nil {
3939
return "", fmt.Errorf("rfb: flush server version: %w", err)
4040
}
4141

@@ -71,7 +71,7 @@ func (c *Conn) OfferSecurityTypes(types []SecurityType) (SecurityType, error) {
7171
return SecInvalid, err
7272
}
7373
}
74-
if err := c.flushLocked(); err != nil {
74+
if err := c.Flush(); err != nil {
7575
return SecInvalid, err
7676
}
7777

@@ -102,15 +102,15 @@ func (c *Conn) SendSecurityFailure(reason string) error {
102102
if err := c.writeRaw([]byte(reason)); err != nil {
103103
return err
104104
}
105-
return c.flushLocked()
105+
return c.Flush()
106106
}
107107

108108
// SendSecurityResultOK signals successful authentication.
109109
func (c *Conn) SendSecurityResultOK() error {
110110
if err := c.writeU32(SecResultOK); err != nil {
111111
return err
112112
}
113-
return c.flushLocked()
113+
return c.Flush()
114114
}
115115

116116
// SendSecurityResultFailed signals failed authentication with an
@@ -126,7 +126,7 @@ func (c *Conn) SendSecurityResultFailed(reason string) error {
126126
if err := c.writeRaw([]byte(reason)); err != nil {
127127
return err
128128
}
129-
return c.flushLocked()
129+
return c.Flush()
130130
}
131131

132132
// ReadClientInit reads the 1-byte ClientInit (shared-flag).
@@ -157,5 +157,5 @@ func (c *Conn) SendServerInit(init ServerInit) error {
157157
if err := c.writeRaw([]byte(init.Name)); err != nil {
158158
return err
159159
}
160-
return c.flushLocked()
160+
return c.Flush()
161161
}

internal/rfb/keysym.go

Lines changed: 56 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,17 @@ func IsModifierKeysym(keysym uint32) bool {
3838
// X11 keysym constants used by the table below. Source: X11/keysymdef.h.
3939
const (
4040
// Whitespace / control
41-
keysymBackSpace uint32 = 0xff08
42-
keysymTab uint32 = 0xff09
43-
keysymLinefeed uint32 = 0xff0a
44-
keysymClear uint32 = 0xff0b
45-
keysymReturn uint32 = 0xff0d
46-
keysymPause uint32 = 0xff13
47-
keysymScrollLock uint32 = 0xff14
48-
keysymSysReq uint32 = 0xff15
49-
keysymEscape uint32 = 0xff1b
50-
keysymDelete uint32 = 0xffff
51-
keysymPrint uint32 = 0xff61
41+
keysymBackSpace uint32 = 0xff08
42+
keysymTab uint32 = 0xff09
43+
keysymLinefeed uint32 = 0xff0a
44+
keysymClear uint32 = 0xff0b
45+
keysymReturn uint32 = 0xff0d
46+
keysymPause uint32 = 0xff13
47+
keysymScrollLock uint32 = 0xff14
48+
keysymSysReq uint32 = 0xff15
49+
keysymEscape uint32 = 0xff1b
50+
keysymDelete uint32 = 0xffff
51+
keysymPrint uint32 = 0xff61
5252

5353
// Cursor / nav
5454
keysymHome uint32 = 0xff50
@@ -114,51 +114,51 @@ const (
114114
// in [internal/usbgadget/hid_keyboard.go] for the modifiers; the rest
115115
// follow the standard 85/101/102-key layout.
116116
const (
117-
hidA byte = 0x04
118-
hid1 byte = 0x1e
119-
hidEnter byte = 0x28
120-
hidEscape byte = 0x29
121-
hidBackspace byte = 0x2a
122-
hidTab byte = 0x2b
123-
hidSpace byte = 0x2c
124-
hidMinus byte = 0x2d
125-
hidEqual byte = 0x2e
126-
hidLeftBracket byte = 0x2f
127-
hidRightBracket byte = 0x30
128-
hidBackslash byte = 0x31
129-
hidSemicolon byte = 0x33
130-
hidApostrophe byte = 0x34
131-
hidGrave byte = 0x35
132-
hidComma byte = 0x36
133-
hidPeriod byte = 0x37
134-
hidSlash byte = 0x38
135-
hidCapsLock byte = 0x39
136-
hidF1 byte = 0x3a
137-
hidPrintScreen byte = 0x46
138-
hidScrollLock byte = 0x47
139-
hidPause byte = 0x48
140-
hidInsert byte = 0x49
141-
hidHome byte = 0x4a
142-
hidPageUp byte = 0x4b
143-
hidDelete byte = 0x4c
144-
hidEnd byte = 0x4d
145-
hidPageDown byte = 0x4e
146-
hidArrowRight byte = 0x4f
147-
hidArrowLeft byte = 0x50
148-
hidArrowDown byte = 0x51
149-
hidArrowUp byte = 0x52
150-
hidNumLock byte = 0x53
151-
hidKpDivide byte = 0x54
152-
hidKpMultiply byte = 0x55
153-
hidKpSubtract byte = 0x56
154-
hidKpAdd byte = 0x57
155-
hidKpEnter byte = 0x58
156-
hidKp1 byte = 0x59
157-
hidKp0 byte = 0x62
158-
hidKpDecimal byte = 0x63
159-
hidContextMenu byte = 0x65
160-
hidKpEqual byte = 0x67
161-
hidF13 byte = 0x68
117+
hidA byte = 0x04
118+
hid1 byte = 0x1e
119+
hidEnter byte = 0x28
120+
hidEscape byte = 0x29
121+
hidBackspace byte = 0x2a
122+
hidTab byte = 0x2b
123+
hidSpace byte = 0x2c
124+
hidMinus byte = 0x2d
125+
hidEqual byte = 0x2e
126+
hidLeftBracket byte = 0x2f
127+
hidRightBracket byte = 0x30
128+
hidBackslash byte = 0x31
129+
hidSemicolon byte = 0x33
130+
hidApostrophe byte = 0x34
131+
hidGrave byte = 0x35
132+
hidComma byte = 0x36
133+
hidPeriod byte = 0x37
134+
hidSlash byte = 0x38
135+
hidCapsLock byte = 0x39
136+
hidF1 byte = 0x3a
137+
hidPrintScreen byte = 0x46
138+
hidScrollLock byte = 0x47
139+
hidPause byte = 0x48
140+
hidInsert byte = 0x49
141+
hidHome byte = 0x4a
142+
hidPageUp byte = 0x4b
143+
hidDelete byte = 0x4c
144+
hidEnd byte = 0x4d
145+
hidPageDown byte = 0x4e
146+
hidArrowRight byte = 0x4f
147+
hidArrowLeft byte = 0x50
148+
hidArrowDown byte = 0x51
149+
hidArrowUp byte = 0x52
150+
hidNumLock byte = 0x53
151+
hidKpDivide byte = 0x54
152+
hidKpMultiply byte = 0x55
153+
hidKpSubtract byte = 0x56
154+
hidKpAdd byte = 0x57
155+
hidKpEnter byte = 0x58
156+
hidKp1 byte = 0x59
157+
hidKp0 byte = 0x62
158+
hidKpDecimal byte = 0x63
159+
hidContextMenu byte = 0x65
160+
hidKpEqual byte = 0x67
161+
hidF13 byte = 0x68
162162

163163
hidLeftControl byte = 0xe0
164164
hidLeftShift byte = 0xe1

internal/rfb/messages.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,20 +110,20 @@ func (c *Conn) ReadClientMessage() (ClientMessage, error) {
110110
if n > maxEncodings {
111111
n = maxEncodings
112112
}
113-
encs := make([]EncodingType, count)
114-
for i := uint16(0); i < count; i++ {
113+
// Allocate based on the capped count, not the wire value, so
114+
// a malicious client can't make us allocate up to 65535*4 bytes.
115+
// We still drain the full wire stream below to keep framing
116+
// aligned for the next message.
117+
encs := make([]EncodingType, n)
118+
for i := 0; i < int(count); i++ {
115119
v, err := c.readS32()
116120
if err != nil {
117121
return nil, err
118122
}
119-
if int(i) < n {
123+
if i < n {
120124
encs[i] = EncodingType(v)
121125
}
122126
}
123-
// If we capped, drop the trailing slots.
124-
if int(count) > n {
125-
encs = encs[:n]
126-
}
127127
return SetEncodingsMessage{Encodings: encs}, nil
128128

129129
case ClientMsgFramebufferUpdateRequest:

internal/rfb/placeholder.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ func PlaceholderImageWithMessage(width, height int, lines []string) []byte {
3939
}
4040

4141
const (
42-
bgR, bgG, bgB uint8 = 0x10, 0x18, 0x40 // dark blue
43-
fgR, fgG, fgB uint8 = 0xFF, 0xFF, 0xFF
44-
lineSpacingPx = 18
42+
bgR, bgG, bgB uint8 = 0x10, 0x18, 0x40 // dark blue
43+
fgR, fgG, fgB uint8 = 0xFF, 0xFF, 0xFF
44+
lineSpacingPx = 18
4545
)
4646

4747
rgba := image.NewRGBA(image.Rect(0, 0, width, height))

internal/rfb/security.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func (c *Conn) PerformVNCAuth(password string) error {
2626
if err := c.writeRaw(challenge[:]); err != nil {
2727
return err
2828
}
29-
if err := c.flushLocked(); err != nil {
29+
if err := c.Flush(); err != nil {
3030
return err
3131
}
3232

ui/src/routes/devices.$id.settings.vnc.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,20 @@ export default function SettingsVncRoute() {
150150
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
151151
{m.vnc_required_client_help()}
152152
</div>
153-
154-
<div className="flex items-center gap-x-2 pt-2">
155-
<Button
156-
size="SM"
157-
theme="primary"
158-
text={saving ? m.saving() : m.vnc_save_button()}
159-
loading={saving}
160-
onClick={handleSave}
161-
/>
162-
</div>
163153
</>
164154
)}
155+
156+
{/* Save button is rendered unconditionally so users can also
157+
persist the disabled state (unchecking the toggle). */}
158+
<div className="flex items-center gap-x-2 pt-2">
159+
<Button
160+
size="SM"
161+
theme="primary"
162+
text={saving ? m.saving() : m.vnc_save_button()}
163+
loading={saving}
164+
onClick={handleSave}
165+
/>
166+
</div>
165167
</div>
166168
</div>
167169
);

0 commit comments

Comments
 (0)