[chore](dev): keep one Delve server across hot reloads

Replace Air's kill-and-rerun loop in the debug image with the pilot
persistent-delve supervisor: src/dev/dlv-watch.sh owns one headless
`dlv exec --multiclient --continue` server, rebuilds ./tmp/main on save and
restarts only the debug target through the tiny rpc2 client in
src/dev/dlv-restart. An attached VS Code session (and its breakpoints) now
survives saves instead of reconnecting after every reload.

The debug compose builds locally with `pull_policy: build` and without
`build.network: host`: compose cannot grant the network.host entitlement, so
a cold `up` previously failed with "additional privileges requested"; the
default bridge reaches the dev Gitea for module resolution.
This commit is contained in:
2026-10-19 09:26:50 +01:00
parent 2ef8d30b47
commit c58e193038
6 changed files with 308 additions and 6 deletions
+5 -3
View File
@@ -55,9 +55,11 @@ EXPOSE 2345
# Set environment variables for development
ENV GO111MODULE=on
# Default command: run Air for hot reloading
# Air will watch for changes and rebuild automatically
CMD ["air", "-c", ".air.toml"]
# Default command: persistent-delve hot-reload supervisor (src/dev/dlv-watch.sh).
# Unlike Air, it never kills the dlv server, so an attached VS Code debug
# session survives file saves (Delve's `restart` only re-launches the target).
# src/ is live-mounted at /app, so this runs the in-repo copy you can edit.
CMD ["bash", "/app/dev/dlv-watch.sh"]
##
+12 -3
View File
@@ -12,16 +12,25 @@ services:
image: keepnote-debug
container_name: keepnote-debug
# Build from our development Dockerfile
# Cold `docker compose -f docker-compose.debug.yaml up -d` builds this image
# directly — no pre-build step needed. Do NOT add `network: host` here:
# `docker compose up`/`build` cannot grant the `network.host` build
# entitlement (that needs `--allow=network.host`, which only `docker build`
# accepts), so a cold up would fail with "additional privileges requested".
# The default bridge reaches the dev Gitea (192.168.122.183), so
# Dockerfile.debug's GOPROXY resolves shared modules without host networking.
build:
context: .
dockerfile: Dockerfile.debug
network: host
# `keepnote-debug` is a local-only tag: build it, never look it up in a
# registry (otherwise a cold `up` warns "pull access denied").
pull_policy: build
# Expose ports: host:container
ports:
- "8081:8080" # Application port
- "8081:8080" # The SINGLE port: API + SPA + /web/* (contract rev 9)
# Delve: unique host port per service (auth-debug owns 2345), so the Go
# attach launch config reaches Air's Delve listener (in-container 2345)
# from the host without conflicts.
+15
View File
@@ -0,0 +1,15 @@
module dlv-restart
go 1.26
require github.com/go-delve/delve v1.27.0
require (
github.com/cilium/ebpf v0.11.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/telemetry v0.0.0-20241106142447-58a1122356f5 // indirect
)
+31
View File
@@ -0,0 +1,31 @@
github.com/cilium/ebpf v0.11.0 h1:V8gS/bTCCjX9uUnkUFUpPsksM8n1lXBAvHcpiFk1X2Y=
github.com/cilium/ebpf v0.11.0/go.mod h1:WE7CZAnqOL2RouJ4f1uyNhqr2P4CCvXFIqdRDUgWsVs=
github.com/creack/pty v1.1.20 h1:VIPb/a2s17qNeQgDnkfZC35RScx+blkKF8GV68n80J4=
github.com/creack/pty v1.1.20/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA=
github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-delve/delve v1.27.0 h1:i66Einw/sQhm0hlbjLNUNxrwCmKdTcIqpyHVqSWAbd0=
github.com/go-delve/delve v1.27.0/go.mod h1:l6Xb1ype6VEKoKZCaOg7mGN5B8Zi3Sn+K7qs5Pqqc8k=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 h1:Jvc7gsqn21cJHCmAWx0LiimpP18LZmUxkT5Mp7EZ1mI=
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20241106142447-58a1122356f5 h1:TCDqnvbBsFapViksHcHySl/sW4+rTGNIAoJJesHRuMM=
golang.org/x/telemetry v0.0.0-20241106142447-58a1122356f5/go.mod h1:8nZWdGp9pq73ZI//QJyckMQab3yq7hoWi7SI0UIusVI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+62
View File
@@ -0,0 +1,62 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// dlv-restart is a minimal JSON-RPC client (delve service/rpc2) that restarts
// the debug target of a persistent headless dlv server and resumes it.
//
// Why not `printf 'restart\n' | dlv connect ...`? `dlv connect` is an
// interactive terminal client: its stdout/stderr is distinct from the debug
// target's, it always reads go-ahead from the same stream, and its stdin EOF
// makes it drop into the (dlv) prompt and exit with a "kill the headless
// instance?" prompt — dumping the debugee's full state and racing the attached
// vscode-go client. This helper speaks rpc2 directly, so it only issues the
// RPCs we want, prints nothing, and exits without disturbing the target or the
// VS Code session.
//
// Usage: dlv-restart [addr] (addr defaults to 127.0.0.1:2345)
package main
import (
"fmt"
"os"
"github.com/go-delve/delve/service/rpc2"
)
func main() {
addr := "127.0.0.1:2345"
if len(os.Args) > 1 {
addr = os.Args[1]
}
client := rpc2.NewClient(addr)
discarded, err := client.RestartFrom(false, "", false, nil, [3]string{}, false)
if err != nil {
fmt.Fprintf(os.Stderr, "dlv-restart: Restart failed: %v\n", err)
os.Exit(1)
}
for _, d := range discarded {
fmt.Fprintf(os.Stderr, "discarded %s: %v\n", d.Breakpoint.Name, d.Reason)
}
// The restarted process begins halted, so resume it with Continue, then
// close the connection. --accept-multiclient keeps the server and target
// running while our short-lived client disconnects; an attached vscode-go
// client is undisturbed.
if err := client.Disconnect(true); err != nil {
fmt.Fprintf(os.Stderr, "dlv-restart: disconnect failed: %v\n", err)
}
}
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Copyright (C) 2026 Dcntrlized
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# dlv-watch: persistent-delve hot-reload supervisor.
#
# Air's hot reload is kill+re-run of its `dlv exec` child, which is why every
# file save drops the VS Code debug session: the debugger itself dies. Delve
# can do better on its own — keep ONE headless dlv server alive for the whole
# session and, on source change, rebuild + restart ONLY the debug target with
# Delve's `restart` command. The server socket (and therefore an attached
# vscode-go legacy/dlv-dap session) never goes away, and Delve re-applies the
# logical breakpoints to the freshly started process — only the ones whose
# source line went stale are discarded.
#
# Two helpers live next to this script (src/dev/, mounted at /app/dev):
# - dlv-restart/ tiny rpc2 client that issues Restart + Continue
# without the dlv connect terminal (no state dumps,
# no "kill the headless instance?" prompt, doesn't
# race the attached VS Code client).
# - the JSON-RPC server Delve's own server, which we never kill.
#
# The dlv server and the debugee's stdout/stderr are streamed to THIS script's
# stdout, so `docker logs keepnote-debug` shows live application logs; the
# "API server listening at:" marker is captured for readiness.
#
# Usage: run as the container CMD (see Dockerfile.debug) with the source
# mounted at /app. Requires: dlv + go on PATH, cap SYS_PTRACE.
set -euo pipefail
APP_DIR=${APP_DIR:-/app}
BIN_DIR=$APP_DIR/tmp
BIN=$BIN_DIR/main
DLV_LISTEN=${DLV_LISTEN:-:2345}
DLV_CONNECT_ADDR=${DLV_CONNECT_ADDR:-127.0.0.1:2345}
BIN_ARGS=${BIN_ARGS:-}
POLL_INTERVAL=${POLL_INTERVAL:-1}
DLV_STATUS=${DLV_STATUS:-}
DLV_PID=""
log() { printf '[dlv-watch] %s\n' "$*"; }
# build produces ./tmp/main with debug metadata; on failure the previous
# binary is untouched (go build writes atomically), so the running target
# keeps serving on errors instead of following Air's stop-on-error.
build() {
log "building ..."
if ! (cd "$APP_DIR" && go build -gcflags='all=-N -l' -o "$BIN" .); then
log "BUILD FAILED - keeping previous process running"
return 1
fi
log "built $BIN"
}
# build_helper compiles the rpc2 restart client (dlv is already in the module
# cache, so no network fetch). It is self-hosted so a source edit is picked up
# on the next restart without rebuilding the image.
build_helper() {
local helper_bin=$BIN_DIR/dlv-restart
if [ -x "$helper_bin" ] && [ "$helper_bin" -nt "$(dirname "$0")/dlv-restart/main.go" ]; then
return 0
fi
log "building dlv-restart helper"
(cd "$(dirname "$0")/dlv-restart" && go build -o "$helper_bin" .) \
|| log "WARNING: could not build dlv-restart helper; target will not reload"
}
# start_dlv launches the one persistent headless server, streaming dlv + the
# debugee's output to stdout (so `docker logs` shows app logs live) while
# capturing the readiness marker. The bare "API server listening at:" line is
# the one unambiguous sign the server thread is bound and accepting — a bare
# TCP probe is NOT enough, because the Docker port mapping (and the host
# `nc -z` in the Launch Backend task) can succeed before dlv's server has
# finished binding, dropping a raced attach ("Socket connection to remote was
# closed").
start_dlv() {
log "starting dlv exec --headless on $DLV_LISTEN"
local marker
marker=$(mktemp)
# tee preserves app logs in this script's stdout (=> docker logs) while also
# writing the same stream to $marker for readiness parsing. The marker needs
# its own fresh fd, so filter lines as they stream.
(
(cd "$APP_DIR" && exec dlv exec --accept-multiclient --headless --continue \
--listen="$DLV_LISTEN" --api-version=2 "$BIN" $BIN_ARGS) 2>&1 \
| tee "$marker"
) &
DLV_PID=$!
for _ in $(seq 1 50); do
if ! kill -0 "$DLV_PID" 2>/dev/null; then
log "dlv exited during startup - cannot confirm readiness"
rm -f "$marker"
return 1
fi
if grep -q "API server listening at:" "$marker"; then
rm -f "$marker"
return 0
fi
sleep 0.2
done
rm -f "$marker"
log "WARNING: dlv did not report 'API server listening' in time"
}
# changed: 0 when any watched file is newer than the current binary (or the
# binary is missing). Excludes tmp/vendor/cache dirs so rebuilds never
# self-trigger. Mirrors Air's include_ext list.
changed() {
[ -f "$BIN" ] || return 0
find "$APP_DIR" \
\( -path "$APP_DIR/tmp" -o -path "$APP_DIR/vendor" -o -path "$APP_DIR/node_modules" -o -path "$APP_DIR/.git" \) -prune -o \
-type f \( -name '*.go' -o -name '*.mod' -o -name '*.sum' -o -name '*.tpl' -o -name '*.tmpl' -o -name '*.html' -o -name '*.env' \) \
-newer "$BIN" -print -quit 2>/dev/null | grep -q .
}
# restart_dlv: uses the rpc2 helper (dlv-restart) to `restart` the target onto
# the freshly built binary (re-execs the same path, carrying logical
# breakpoints over) and then `continue` it (the restarted process starts
# halted). This is a clean RPC-only client: it never takes over the interactive
# terminal, doesn't dump the debugee's state, and exits immediately, so it
# neither races nor disturbs an attached vscode-go client.
restart_dlv() {
log "restarting debug target with new binary"
if ! "$BIN_DIR/dlv-restart" "$DLV_CONNECT_ADDR"; then
log "restart request failed"
return 1
fi
return 0
}
main() {
mkdir -p "$BIN_DIR"
build_helper
while true; do
if [ ! -f "$BIN" ]; then
log "no binary yet - building first"
build || true
sleep "$POLL_INTERVAL"
continue
fi
# (Re)build the helper lazily in case its source changed since the last
# successful startup (self-hosted, no image rebuild).
build_helper
if ! kill -0 "$DLV_PID" 2>/dev/null; then
log "dlv server not running - starting it"
start_dlv
fi
if changed; then
if build; then
restart_dlv || true
fi
fi
sleep "$POLL_INTERVAL"
done
}
_trap() {
log "stopping (signal received)"
[ -n "$DLV_PID" ] && kill "$DLV_PID" 2>/dev/null || true
exit 0
}
trap _trap INT TERM
main