From c58e1930380a6033f9f3ebf89e5d57e5dfc5188f Mon Sep 17 00:00:00 2001
From: Admin <1+admin@noreply.git.dcntrlized.org>
Date: Mon, 19 Oct 2026 09:26:50 +0100
Subject: [PATCH] [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.
---
Dockerfile.debug | 8 +-
docker-compose.debug.yaml | 15 ++-
src/dev/dlv-restart/go.mod | 15 +++
src/dev/dlv-restart/go.sum | 31 ++++++
src/dev/dlv-restart/main.go | 62 ++++++++++++
src/dev/dlv-watch.sh | 183 ++++++++++++++++++++++++++++++++++++
6 files changed, 308 insertions(+), 6 deletions(-)
create mode 100644 src/dev/dlv-restart/go.mod
create mode 100644 src/dev/dlv-restart/go.sum
create mode 100644 src/dev/dlv-restart/main.go
create mode 100755 src/dev/dlv-watch.sh
diff --git a/Dockerfile.debug b/Dockerfile.debug
index b1b0f3d..f7173c8 100644
--- a/Dockerfile.debug
+++ b/Dockerfile.debug
@@ -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"]
##
diff --git a/docker-compose.debug.yaml b/docker-compose.debug.yaml
index 36ea615..32eef13 100644
--- a/docker-compose.debug.yaml
+++ b/docker-compose.debug.yaml
@@ -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.
diff --git a/src/dev/dlv-restart/go.mod b/src/dev/dlv-restart/go.mod
new file mode 100644
index 0000000..f46dc57
--- /dev/null
+++ b/src/dev/dlv-restart/go.mod
@@ -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
+)
diff --git a/src/dev/dlv-restart/go.sum b/src/dev/dlv-restart/go.sum
new file mode 100644
index 0000000..890134b
--- /dev/null
+++ b/src/dev/dlv-restart/go.sum
@@ -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=
diff --git a/src/dev/dlv-restart/main.go b/src/dev/dlv-restart/main.go
new file mode 100644
index 0000000..e83fbc5
--- /dev/null
+++ b/src/dev/dlv-restart/main.go
@@ -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 .
+
+// 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)
+ }
+}
diff --git a/src/dev/dlv-watch.sh b/src/dev/dlv-watch.sh
new file mode 100755
index 0000000..4d564e7
--- /dev/null
+++ b/src/dev/dlv-watch.sh
@@ -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 .
+
+# 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
\ No newline at end of file