-
Notifications
You must be signed in to change notification settings - Fork 0
fix: docker.sock reliability + release 1.0.7 #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9293f5a
276dc24
d8f3f98
c06d389
8d5ff56
54184ce
374a58d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package daemon | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "fmt" | ||
| "io" | ||
|
|
@@ -16,12 +17,24 @@ import ( | |
| ) | ||
|
|
||
| const ( | ||
| dockerProxyMaxConcurrent = 16 | ||
| // dockerProxyMaxConcurrent caps simultaneous dials into krunkit vsock. | ||
| // Keep this modest: high fan-out floods virtio-vsock. Too low deadlocks | ||
| // clients (e.g. docker CLI) that open more than one connection per command. | ||
| dockerProxyMaxConcurrent = 8 | ||
| dockerProxyReclaimInterval = 2 * time.Second | ||
| dockerProxyDialProbe = 2 * time.Second | ||
| dockerProxyDialAfterWake = 30 * time.Second | ||
| dockerProxyMaxHTTPHeader = 1 << 20 | ||
|
|
||
| dockerProxyModeListen int32 = 0 | ||
| ) | ||
|
|
||
| // engineConnGater limits concurrent use of the guest Docker engine socket (vsock). | ||
| type engineConnGater interface { | ||
| AcquireEngineConn(ctx context.Context) error | ||
| ReleaseEngineConn() | ||
| } | ||
|
|
||
| // dockerSocketProxy listens on the public Docker CLI socket and forwards to the | ||
| // engine vsock socket. When the engine is stopped (Resource Saver), the first | ||
| // CLI connection wakes it via EnsureRuntimeRunning before forwarding. | ||
|
|
@@ -35,6 +48,7 @@ type dockerSocketProxy struct { | |
| engine string | ||
| wake func(context.Context) error | ||
| lifecycle context.Context | ||
| gater engineConnGater | ||
| gate chan struct{} | ||
| mode atomic.Int32 | ||
| switchMu sync.Mutex | ||
|
|
@@ -52,13 +66,14 @@ type engineDockerSocketer interface { | |
| } | ||
|
|
||
| // newDockerSocketProxy builds a wake-on-connect proxy when public and engine paths differ. | ||
| func newDockerSocketProxy(logger *slog.Logger, public, engine string, lifecycle context.Context, wake func(context.Context) error) *dockerSocketProxy { | ||
| func newDockerSocketProxy(logger *slog.Logger, public, engine string, lifecycle context.Context, wake func(context.Context) error, gater engineConnGater) *dockerSocketProxy { | ||
| return &dockerSocketProxy{ | ||
| logger: logger, | ||
| public: public, | ||
| engine: engine, | ||
| wake: wake, | ||
| lifecycle: lifecycle, | ||
| gater: gater, | ||
| gate: make(chan struct{}, dockerProxyMaxConcurrent), | ||
| stopCh: make(chan struct{}), | ||
| doneCh: make(chan struct{}), | ||
|
|
@@ -287,24 +302,22 @@ func (p *dockerSocketProxy) handle(client net.Conn) { | |
| if p.lifecycle != nil { | ||
| parent = p.lifecycle | ||
| } | ||
| select { | ||
| case p.gate <- struct{}{}: | ||
| defer func() { <-p.gate }() | ||
| case <-parent.Done(): | ||
| if err := p.acquireConn(parent); err != nil { | ||
| return | ||
| } | ||
| defer p.releaseConn() | ||
|
|
||
| ctx, cancel := context.WithTimeout(parent, constants.GuestDiskFetchTimeout+3*time.Minute) | ||
| defer cancel() | ||
|
|
||
| server, err := p.dialEngine(ctx, 400*time.Millisecond) | ||
| server, err := p.dialEngine(ctx, dockerProxyDialProbe) | ||
| if err != nil { | ||
| p.logger.Info("docker CLI connected while engine stopped; waking") | ||
| if wakeErr := p.wake(ctx); wakeErr != nil { | ||
| p.logger.Warn("docker socket wake failed", "error", wakeErr) | ||
| return | ||
| } | ||
| server, err = p.dialEngine(ctx, 30*time.Second) | ||
| server, err = p.dialEngine(ctx, dockerProxyDialAfterWake) | ||
| if err != nil { | ||
| p.logger.Warn("docker socket dial engine failed", "error", err) | ||
| return | ||
|
|
@@ -315,6 +328,31 @@ func (p *dockerSocketProxy) handle(client net.Conn) { | |
| proxyUnixConnection(client, server) | ||
| } | ||
|
|
||
| // acquireConn takes a shared vsock slot (or the local gate when no gater is set). | ||
| func (p *dockerSocketProxy) acquireConn(ctx context.Context) error { | ||
| if p.gater != nil { | ||
| return p.gater.AcquireEngineConn(ctx) | ||
| } | ||
| select { | ||
| case p.gate <- struct{}{}: | ||
| return nil | ||
| case <-ctx.Done(): | ||
| return ctx.Err() | ||
| } | ||
| } | ||
|
|
||
| // releaseConn frees the slot taken by acquireConn. | ||
| func (p *dockerSocketProxy) releaseConn() { | ||
| if p.gater != nil { | ||
| p.gater.ReleaseEngineConn() | ||
| return | ||
| } | ||
| select { | ||
| case <-p.gate: | ||
| default: | ||
| } | ||
| } | ||
|
|
||
| // dialEngine connects to the krunkit vsock socket, retrying until timeout. | ||
| func (p *dockerSocketProxy) dialEngine(ctx context.Context, timeout time.Duration) (net.Conn, error) { | ||
| var d net.Dialer | ||
|
|
@@ -341,34 +379,108 @@ func (p *dockerSocketProxy) dialEngine(ctx context.Context, timeout time.Duratio | |
| return nil, lastErr | ||
| } | ||
|
|
||
| // proxyUnixConnection copies bytes both ways and half-closes on EOF. | ||
| // proxyUnixConnection forwards one Docker API connection. | ||
| // | ||
| // Plain HTTP requests get Connection: close so dockerd ends the response and | ||
| // frees the vsock slot (keep-alive would wedge after the CLI half-closes). | ||
| // Hijacked streams (Upgrade: tcp) keep both directions open without | ||
| // CloseWrite on the engine side — vsock treats half-close as full teardown, | ||
| // which drops `docker run`/`exec` stdout. | ||
| func proxyUnixConnection(client, server net.Conn) { | ||
| done := make(chan struct{}, 2) | ||
| head, rest, upgrade, err := readDockerAPIRequestHead(client) | ||
| if err != nil { | ||
| return | ||
| } | ||
| if !upgrade { | ||
| head = forceHTTPConnectionClose(head) | ||
| } | ||
| if _, err := server.Write(head); err != nil { | ||
| return | ||
| } | ||
|
|
||
| clientReader := io.Reader(client) | ||
| if len(rest) > 0 { | ||
| clientReader = io.MultiReader(bytes.NewReader(rest), client) | ||
| } | ||
|
|
||
| done := make(chan struct{}, 2) | ||
| go func() { | ||
| _, _ = io.Copy(server, client) | ||
| closeWriteConn(server) | ||
| _, _ = io.Copy(server, clientReader) | ||
| done <- struct{}{} | ||
| }() | ||
|
|
||
| go func() { | ||
| _, _ = io.Copy(client, server) | ||
| closeWriteConn(client) | ||
| done <- struct{}{} | ||
| }() | ||
|
|
||
| <-done | ||
| <-done | ||
| if upgrade { | ||
| <-done | ||
| <-done | ||
| } else { | ||
| <-done | ||
| _ = client.Close() | ||
| _ = server.Close() | ||
| <-done | ||
| } | ||
| _ = client.Close() | ||
| _ = server.Close() | ||
|
Comment on lines
+416
to
+426
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
file=backend/internal/daemon/docker_socket_proxy.go
test_file=backend/test/daemon/docker_socket_proxy_test.go
printf '%s\n' '--- target file outline ---'
ast-grep outline "$file" 2>/dev/null || true
printf '%s\n' '--- target implementation ---'
sed -n '330,445p' "$file"
printf '%s\n' '--- related test outline ---'
ast-grep outline "$test_file" 2>/dev/null || true
printf '%s\n' '--- related test ---'
sed -n '320,430p' "$test_file"
printf '%s\n' '--- relevant symbols and imports ---'
rg -n 'io\.Copy|done|upgrade|CloseWrite|dockerProxyPlain|time\.After|net\.Pipe|Serve|proxy' "$file" "$test_file"Repository: enegalan/calf Length of output: 17368 🏁 Script executed: #!/bin/bash
set -eu
file=backend/internal/daemon/docker_socket_proxy.go
test_file=backend/test/daemon/docker_socket_proxy_test.go
printf '%s\n' '--- target implementation ---'
sed -n '330,445p' "$file"
printf '%s\n' '--- related test ---'
sed -n '320,430p' "$test_file"
printf '%s\n' '--- relevant symbols ---'
rg -n 'io\.Copy|done|upgrade|CloseWrite|dockerProxyPlain|time\.After|net\.Pipe|Serve|proxy' "$file" "$test_file"Repository: enegalan/calf Length of output: 14255 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- complete half-close regression test ---'
sed -n '250,370p' backend/test/daemon/docker_socket_proxy_test.go
printf '%s\n' '--- proxy call path and connection ownership ---'
sed -n '280,335p' backend/internal/daemon/docker_socket_proxy.go
sed -n '1,130p' backend/internal/daemon/docker_socket_proxy.go
printf '%s\n' '--- all proxy tests and package metadata ---'
rg -n -C 3 'ProxyUnixConnection|CloseWrite|Connection: close|Content-Length|http\.Client|unix' backend/test/daemon backend/internal/daemon
fd -a 'go.mod|go.work' .Repository: enegalan/calf Length of output: 23128 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- complete half-close regression test ---'
sed -n '250,370p' backend/test/daemon/docker_socket_proxy_test.go
printf '%s\n' '--- proxy call path and connection ownership ---'
sed -n '280,335p' backend/internal/daemon/docker_socket_proxy.go
sed -n '1,130p' backend/internal/daemon/docker_socket_proxy.go
printf '%s\n' '--- related tests and package metadata ---'
rg -n -C 3 'ProxyUnixConnection|CloseWrite|Connection: close|Content-Length|http\.Client|unix' backend/test/daemon backend/internal/daemon
fd -a 'go.mod|go.work' .Repository: enegalan/calf Length of output: 23126 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
import socket
import threading
import time
def copy_direction(src, dst, finished):
try:
while True:
data = src.recv(4096)
if not data:
return
dst.sendall(data)
except OSError:
return
finally:
finished.set()
def run(current_behavior):
client_proxy, client = socket.socketpair()
server_proxy, server = socket.socketpair()
client.settimeout(1)
received = bytearray()
client_proxy.sendall(
b"GET /_ping HTTP/1.1\r\nHost: localhost\r\n\r\n"
)
client_done = threading.Event()
server_done = threading.Event()
threading.Thread(
target=copy_direction,
args=(client_proxy, server, client_done),
daemon=True,
).start()
threading.Thread(
target=copy_direction,
args=(server_proxy, client_proxy, server_done),
daemon=True,
).start()
# The plain HTTP client has sent its request and half-closes its write side.
client.shutdown(socket.SHUT_WR)
client_done.wait(1)
if current_behavior:
# This is the current unordered `done` path when client_done wins.
client_proxy.close()
server_proxy.close()
else:
# This models waiting for the response direction, with a bounded grace.
server.sendall(
b"HTTP/1.1 200 OK\r\nConnection: close\r\n"
b"Content-Length: 2\r\n\r\nOK"
)
server.shutdown(socket.SHUT_WR)
if not server_done.wait(1):
client_proxy.close()
server_proxy.close()
if current_behavior:
# The engine attempts the response after the proxy has closed its side.
try:
server.sendall(
b"HTTP/1.1 200 OK\r\nConnection: close\r\n"
b"Content-Length: 2\r\n\r\nOK"
)
except OSError:
pass
try:
while True:
data = client.recv(4096)
if not data:
break
received.extend(data)
except (socket.timeout, OSError):
pass
for conn in (client, server):
try:
conn.close()
except OSError:
pass
return bytes(received)
current = run(True)
fixed = run(False)
print("current response bytes:", len(current), repr(current))
print("server-direction-wait response:", len(fixed), repr(fixed))
assert b"HTTP/1.1 200 OK" not in current
assert b"OK" in fixed
PYRepository: enegalan/calf Length of output: 380 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
import socket
import threading
def copy_direction(src, dst, finished):
try:
while True:
data = src.recv(4096)
if not data:
return
dst.sendall(data)
except OSError:
return
finally:
finished.set()
def run(current_behavior):
client_proxy, client = socket.socketpair()
server_proxy, server = socket.socketpair()
client.settimeout(1)
client_proxy.sendall(b"GET /_ping HTTP/1.1\r\nHost: localhost\r\n\r\n")
client_done = threading.Event()
server_done = threading.Event()
threading.Thread(
target=copy_direction,
args=(client_proxy, server, client_done),
daemon=True,
).start()
threading.Thread(
target=copy_direction,
args=(server_proxy, client_proxy, server_done),
daemon=True,
).start()
client.shutdown(socket.SHUT_WR)
client_done.wait(1)
response = (
b"HTTP/1.1 200 OK\r\nConnection: close\r\n"
b"Content-Length: 2\r\n\r\nOK"
)
if current_behavior:
client_proxy.close()
server_proxy.close()
try:
server.sendall(response)
except OSError:
pass
else:
server.sendall(response)
server.shutdown(socket.SHUT_WR)
if not server_done.wait(1):
client_proxy.close()
server_proxy.close()
received = bytearray()
try:
while True:
data = client.recv(4096)
if not data:
break
received.extend(data)
except (socket.timeout, OSError):
pass
for conn in (client, server):
try:
conn.close()
except OSError:
pass
return bytes(received)
current = run(True)
fixed = run(False)
print("current response bytes:", len(current), repr(current))
print("server-direction-wait response:", len(fixed), repr(fixed))
assert b"HTTP/1.1 200 OK" not in current
assert b"OK" in fixed
PYRepository: enegalan/calf Length of output: 380 Wait for the plain HTTP response before closing the proxy. When the client half-closes, the client-to-server copy can finish first. The current code then closes both connections and truncates dockerd’s response. Track each copy direction separately, wait for server-to-client completion, and use a bounded grace period. Add a TCP test that calls 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // closeWriteConn half-closes the write side when the connection type supports it. | ||
| func closeWriteConn(conn net.Conn) { | ||
| type closeWriter interface { | ||
| CloseWrite() error | ||
| // readDockerAPIRequestHead reads until the end of HTTP headers (or EOF). | ||
| func readDockerAPIRequestHead(r io.Reader) (head, rest []byte, upgrade bool, err error) { | ||
| buf := make([]byte, 0, 4096) | ||
| tmp := make([]byte, 2048) | ||
| for { | ||
| if len(buf) > dockerProxyMaxHTTPHeader { | ||
| return nil, nil, false, fmt.Errorf("docker API headers exceed %d bytes", dockerProxyMaxHTTPHeader) | ||
| } | ||
| n, readErr := r.Read(tmp) | ||
| if n > 0 { | ||
| buf = append(buf, tmp[:n]...) | ||
| if idx := bytes.Index(buf, []byte("\r\n\r\n")); idx >= 0 { | ||
| head = buf[:idx+4] | ||
| rest = buf[idx+4:] | ||
| return head, rest, httpRequestHeadIsUpgrade(head), nil | ||
| } | ||
| } | ||
| if readErr != nil { | ||
| if len(buf) == 0 { | ||
| return nil, nil, false, readErr | ||
| } | ||
| if readErr == io.EOF { | ||
| return buf, nil, httpRequestHeadIsUpgrade(buf), nil | ||
| } | ||
| return nil, nil, false, readErr | ||
| } | ||
| } | ||
| if cw, ok := conn.(closeWriter); ok { | ||
| _ = cw.CloseWrite() | ||
| } | ||
|
|
||
| // httpRequestHeadIsUpgrade reports a Docker API hijack (attach/exec/raw stream). | ||
| func httpRequestHeadIsUpgrade(head []byte) bool { | ||
| return bytes.Contains(bytes.ToLower(head), []byte("\r\nupgrade:")) | ||
| } | ||
|
|
||
| // forceHTTPConnectionClose strips Connection headers and adds Connection: close. | ||
| func forceHTTPConnectionClose(head []byte) []byte { | ||
| if len(head) == 0 { | ||
| return head | ||
| } | ||
| trimmed := bytes.TrimSuffix(head, []byte("\r\n\r\n")) | ||
| lines := bytes.Split(trimmed, []byte("\r\n")) | ||
| out := make([][]byte, 0, len(lines)+1) | ||
| for i, line := range lines { | ||
| if i == 0 { | ||
| out = append(out, line) | ||
| continue | ||
| } | ||
| lower := bytes.ToLower(line) | ||
| if bytes.HasPrefix(lower, []byte("connection:")) { | ||
| continue | ||
| } | ||
| out = append(out, line) | ||
| } | ||
| out = append(out, []byte("Connection: close")) | ||
| return append(bytes.Join(out, []byte("\r\n")), []byte("\r\n\r\n")...) | ||
| } | ||
|
|
||
| // resolveEngineDockerSocket returns the vsock path when the runtime exposes one. | ||
|
|
@@ -383,10 +495,15 @@ func resolveEngineDockerSocket(rt interface{ DockerSocket() string }) string { | |
|
|
||
| // NewDockerSocketProxyForTest constructs a wake-on-connect proxy for unit tests. | ||
| func NewDockerSocketProxyForTest(public, engine string, lifecycle context.Context, wake func(context.Context) error) *DockerSocketProxy { | ||
| inner := newDockerSocketProxy(slog.Default(), public, engine, lifecycle, wake) | ||
| inner := newDockerSocketProxy(slog.Default(), public, engine, lifecycle, wake, nil) | ||
| return &DockerSocketProxy{inner: inner} | ||
| } | ||
|
|
||
| // ProxyUnixConnectionForTest exposes proxyUnixConnection for leak-regression tests. | ||
| func ProxyUnixConnectionForTest(client, server net.Conn) { | ||
| proxyUnixConnection(client, server) | ||
| } | ||
|
|
||
| // DockerSocketProxy is the exported test/handle surface for the public Docker CLI socket proxy. | ||
| type DockerSocketProxy struct { | ||
| inner *dockerSocketProxy | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Bound the connection-slot wait with an explicit timeout.
acquireConn(parent)blocks on the shared gate.parentisp.lifecycleorcontext.Background(), so neither carries a deadline. If the guest holds all 8 slots (for example during a longdocker logs -forAttachExec), each new client connection parks a goroutine until the daemon shuts down, and the CLI sees a hang instead of an error.Create the bounded context first, then acquire with it. This also matches the guideline to thread an explicit timeout into blocking runtime calls.
🔧 Proposed fix
Add the constant next to the other proxy timeouts:
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines