Skip to content

Commit 89ccafa

Browse files
authored
fix: prevent daemon CLI timeouts on slow storage (#518)
Slow NAS storage could outlast fixed daemon client and server deadlines, causing CLI, TUI, MCP, statistics, and backup requests to fail while useful work was still progressing. These daemon-backed operations now wait for completion or caller cancellation, while discovery, connection setup, browser traffic, and ordinary API clients retain protective bounds. Generated NAS Compose bundles now reconcile the moving GHCR `latest` image with `pull_policy: always`. Operators can update with `docker compose up -d`; existing bundles should be regenerated, edited to add the policy, or updated explicitly with `docker compose pull` followed by `docker compose up -d`. A restart alone continues to reuse the installed image. Validation: `go vet -tags "fts5 sqlite_vec" ./...`, `make test`, and `make lint`. Co-authored-by: Wes McKinney <wesm@users.noreply.github.com>
1 parent 7fbec02 commit 89ccafa

56 files changed

Lines changed: 3558 additions & 408 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/docker-pr.yml

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
name: Docker
22

3-
# The PR dispatcher always calls the main-pinned workflow. That workflow
4-
# independently routes same-repository Linux jobs to the managed public fleet
5-
# and fork jobs to GitHub-hosted runners.
3+
# Build and smoke-test the PR image without registry credentials. Keep this
4+
# branch-defined workflow on a GitHub-hosted runner.
65
on:
76
pull_request:
87
paths:
@@ -22,5 +21,49 @@ on:
2221
permissions: read-all
2322

2423
jobs:
25-
run:
26-
uses: kenn-io/msgvault/.github/workflows/docker.yml@main
24+
validate:
25+
runs-on: ubuntu-latest
26+
27+
steps:
28+
- name: Checkout
29+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
30+
31+
- name: Set up Docker Buildx
32+
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
33+
34+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
35+
with:
36+
bun-version: 1.3.14
37+
38+
- name: Build browser application
39+
run: make web-assets-check
40+
41+
- name: Build amd64 image
42+
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
43+
with:
44+
context: .
45+
platforms: linux/amd64
46+
push: false
47+
load: true
48+
tags: msgvault:test
49+
build-args: |
50+
VERSION=test
51+
COMMIT=${{ github.sha }}
52+
BUILD_DATE=test
53+
cache-from: type=gha
54+
cache-to: type=gha,mode=max
55+
56+
- name: Smoke test (amd64)
57+
run: |
58+
docker run --rm msgvault:test version
59+
docker run --rm msgvault:test --help
60+
61+
tmpdir="$(mktemp -d)"
62+
trap 'sudo rm -rf "$tmpdir"' EXIT
63+
sudo chown 1000:1000 "$tmpdir"
64+
docker run --rm -v "$tmpdir:/data" msgvault:test init-db
65+
sudo test -f "$tmpdir/msgvault.db" || { sudo ls -la "$tmpdir"; echo "FATAL: database not created"; exit 1; }
66+
67+
# Runtime is binary-only: no frontend toolchain or external asset tree.
68+
docker run --rm --entrypoint sh msgvault:test -c \
69+
'test ! -e /usr/local/bin/bun && test ! -e /usr/local/bin/node && test ! -d /usr/share/msgvault/web'

.github/workflows/docker.yml

Lines changed: 1 addition & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
name: Docker
22

33
on:
4-
workflow_call:
54
push:
65
branches: [main]
76
tags:
@@ -11,61 +10,9 @@ env:
1110
IMAGE_NAME: ${{ github.repository }}
1211

1312
jobs:
14-
# PR validation: build and smoke-test only, no registry access
15-
validate:
16-
if: github.event_name == 'pull_request'
17-
runs-on: ${{ github.repository == 'kenn-io/msgvault' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'kenn-linux-x64-public' || 'ubuntu-latest' }}
18-
permissions:
19-
contents: read
20-
21-
steps:
22-
- name: Checkout
23-
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
24-
25-
- name: Set up Docker Buildx
26-
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
27-
28-
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
29-
with:
30-
bun-version: 1.3.14
31-
32-
- name: Build browser application
33-
run: make web-assets-check
34-
35-
- name: Build amd64 image
36-
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
37-
with:
38-
context: .
39-
platforms: linux/amd64
40-
push: false
41-
load: true
42-
tags: msgvault:test
43-
build-args: |
44-
VERSION=test
45-
COMMIT=${{ github.sha }}
46-
BUILD_DATE=test
47-
cache-from: type=gha
48-
cache-to: type=gha,mode=max
49-
50-
- name: Smoke test (amd64)
51-
run: |
52-
docker run --rm msgvault:test version
53-
docker run --rm msgvault:test --help
54-
55-
tmpdir="$(mktemp -d)"
56-
trap 'sudo rm -rf "$tmpdir"' EXIT
57-
sudo chown 1000:1000 "$tmpdir"
58-
docker run --rm -v "$tmpdir:/data" msgvault:test init-db
59-
sudo test -f "$tmpdir/msgvault.db" || { sudo ls -la "$tmpdir"; echo "FATAL: database not created"; exit 1; }
60-
61-
# Runtime is binary-only: no frontend toolchain or external asset tree.
62-
docker run --rm --entrypoint sh msgvault:test -c \
63-
'test ! -e /usr/local/bin/bun && test ! -e /usr/local/bin/node && test ! -d /usr/share/msgvault/web'
64-
6513
# Publish: build multi-arch and push to GHCR (main/tags only)
6614
publish:
67-
if: github.event_name != 'pull_request'
68-
runs-on: ${{ github.repository == 'kenn-io/msgvault' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'kenn-linux-x64-public' || 'ubuntu-latest' }}
15+
runs-on: ${{ github.repository == 'kenn-io/msgvault' && github.ref == 'refs/heads/main' && 'kenn-linux-x64-public' || 'ubuntu-latest' }}
6916
permissions:
7017
contents: read
7118
packages: write

cmd/msgvault/cmd/backup.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ func runBackupCreateLocal(cmd *cobra.Command) error {
475475
}
476476
defer func() { _ = blobs.Close() }()
477477

478-
freezer, closeFreezer, err := newBackupFreezer()
478+
freezer, closeFreezer, err := newBackupFreezer(cmd.Context())
479479
if err != nil {
480480
return err
481481
}
@@ -533,13 +533,16 @@ func runBackupCreateLocal(cmd *cobra.Command) error {
533533
// freezeViaDaemon coordinator over it. backup create must never scan a
534534
// live-daemon-owned SQLite file unfrozen, so a daemon that cannot be
535535
// resolved here is a hard failure rather than a silent unfrozen fallback.
536-
func newBackupFreezer() (backup.FreezeCoordinator, func(), error) {
536+
func newBackupFreezer(ctx context.Context) (backup.FreezeCoordinator, func(), error) {
537537
rt := findDaemonRuntime(cfg.Data.DataDir)
538538
if rt == nil {
539539
return nil, func() {}, errors.New(
540540
"backup create: no running msgvault daemon found; refusing to back up an unfrozen archive")
541541
}
542-
client, err := daemonclient.New(daemonclient.Config{
542+
// Begin and End deliberately use separate per-call contexts. Keep command
543+
// values on the client root without letting command cancellation poison the
544+
// cleanup request that releases an already-open freeze window.
545+
client, err := newDaemonCLIClient(context.WithoutCancel(ctx), daemonclient.Config{
543546
URL: urlFromDaemonRuntime(rt),
544547
APIKey: cfg.Server.APIKey,
545548
AllowInsecure: true,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"sync/atomic"
9+
"testing"
10+
"time"
11+
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
"go.kenn.io/kit/daemon"
15+
"go.kenn.io/msgvault/internal/apiprotocol"
16+
"go.kenn.io/msgvault/pkg/client/generated"
17+
)
18+
19+
func TestNewBackupFreezerUsesCommandContextAndCLIMode(t *testing.T) {
20+
assert := assert.New(t)
21+
require := require.New(t)
22+
var marker atomic.Value
23+
requestCanceled := make(chan struct{})
24+
mux := http.NewServeMux()
25+
mux.Handle("/api/ping", daemon.NewPingHandler(daemon.PingHandlerOptions{
26+
Service: daemonService,
27+
Version: Version,
28+
}))
29+
mux.HandleFunc("/api/v1/backup/freeze/begin", func(w http.ResponseWriter, r *http.Request) {
30+
assert.Equal("/api/v1/backup/freeze/begin", r.URL.Path)
31+
marker.Store(r.Header.Get(apiprotocol.ClientClassHeader))
32+
<-r.Context().Done()
33+
close(requestCanceled)
34+
})
35+
srv := httptest.NewServer(mux)
36+
t.Cleanup(srv.Close)
37+
38+
dataDir := t.TempDir()
39+
withStoreResolverConfig(t, lifecycleTestConfig(dataDir))
40+
rt := daemonRuntimeForHTTPServer(t, srv, daemonAPIKeyFingerprint(""))
41+
_, err := daemonRuntimeStore(dataDir).Write(rt.Record)
42+
require.NoError(err, "write daemon runtime")
43+
44+
ctx, cancel := context.WithCancel(context.Background())
45+
freezer, closeFreezer, err := newBackupFreezer(ctx)
46+
require.NoError(err, "newBackupFreezer")
47+
t.Cleanup(closeFreezer)
48+
49+
done := make(chan error, 1)
50+
go func() {
51+
done <- freezer.Begin(ctx)
52+
}()
53+
require.Eventually(func() bool {
54+
return marker.Load() != nil
55+
}, 2*time.Second, 10*time.Millisecond, "freeze request starts")
56+
cancel()
57+
58+
require.Eventually(func() bool {
59+
select {
60+
case <-requestCanceled:
61+
return true
62+
default:
63+
return false
64+
}
65+
}, 2*time.Second, 10*time.Millisecond)
66+
assert.Equal(apiprotocol.ClientClassCLI, marker.Load())
67+
require.Error(<-done, "freeze request canceled")
68+
}
69+
70+
func TestNewBackupFreezerEndsFreezeAfterCommandCancellation(t *testing.T) {
71+
assert := assert.New(t)
72+
require := require.New(t)
73+
var beginMarker atomic.Value
74+
var endMarker atomic.Value
75+
76+
mux := http.NewServeMux()
77+
mux.Handle("/api/ping", daemon.NewPingHandler(daemon.PingHandlerOptions{
78+
Service: daemonService,
79+
Version: Version,
80+
}))
81+
mux.HandleFunc("/api/v1/backup/freeze/begin", func(w http.ResponseWriter, r *http.Request) {
82+
assert.Equal(http.MethodPost, r.Method)
83+
beginMarker.Store(r.Header.Get(apiprotocol.ClientClassHeader))
84+
w.Header().Set("Content-Type", "application/json")
85+
assert.NoError(json.NewEncoder(w).Encode(generated.BackupFreezeBeginResponse{
86+
Token: "freeze-token",
87+
}))
88+
})
89+
mux.HandleFunc("/api/v1/backup/freeze/end", func(w http.ResponseWriter, r *http.Request) {
90+
assert.Equal(http.MethodPost, r.Method)
91+
endMarker.Store(r.Header.Get(apiprotocol.ClientClassHeader))
92+
var request generated.BackupFreezeEndRequest
93+
assert.NoError(json.NewDecoder(r.Body).Decode(&request))
94+
assert.Equal("freeze-token", request.Token)
95+
w.Header().Set("Content-Type", "application/json")
96+
_, err := w.Write([]byte(`{}`))
97+
assert.NoError(err)
98+
})
99+
srv := httptest.NewServer(mux)
100+
t.Cleanup(srv.Close)
101+
102+
dataDir := t.TempDir()
103+
withStoreResolverConfig(t, lifecycleTestConfig(dataDir))
104+
rt := daemonRuntimeForHTTPServer(t, srv, daemonAPIKeyFingerprint(""))
105+
_, err := daemonRuntimeStore(dataDir).Write(rt.Record)
106+
require.NoError(err, "write daemon runtime")
107+
108+
commandCtx, cancelCommand := context.WithCancel(context.Background())
109+
freezer, closeFreezer, err := newBackupFreezer(commandCtx)
110+
require.NoError(err, "newBackupFreezer")
111+
t.Cleanup(closeFreezer)
112+
113+
require.NoError(freezer.Begin(commandCtx), "begin freeze")
114+
cancelCommand()
115+
116+
cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 2*time.Second)
117+
defer cancelCleanup()
118+
require.NoError(freezer.End(cleanupCtx), "end freeze after command cancellation")
119+
assert.Equal(apiprotocol.ClientClassCLI, beginMarker.Load())
120+
assert.Equal(apiprotocol.ClientClassCLI, endMarker.Load())
121+
}

0 commit comments

Comments
 (0)