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