[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:
+5
-3
@@ -55,9 +55,11 @@ EXPOSE 2345
|
|||||||
# Set environment variables for development
|
# Set environment variables for development
|
||||||
ENV GO111MODULE=on
|
ENV GO111MODULE=on
|
||||||
|
|
||||||
# Default command: run Air for hot reloading
|
# Default command: persistent-delve hot-reload supervisor (src/dev/dlv-watch.sh).
|
||||||
# Air will watch for changes and rebuild automatically
|
# Unlike Air, it never kills the dlv server, so an attached VS Code debug
|
||||||
CMD ["air", "-c", ".air.toml"]
|
# 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,16 +12,25 @@ services:
|
|||||||
image: keepnote-debug
|
image: keepnote-debug
|
||||||
container_name: 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:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.debug
|
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
|
# Expose ports: host:container
|
||||||
ports:
|
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
|
# Delve: unique host port per service (auth-debug owns 2345), so the Go
|
||||||
# attach launch config reaches Air's Delve listener (in-container 2345)
|
# attach launch config reaches Air's Delve listener (in-container 2345)
|
||||||
# from the host without conflicts.
|
# from the host without conflicts.
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+183
@@ -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
|
||||||
Reference in New Issue
Block a user