Skip to content

Commit 3df19d0

Browse files
committed
fix: activities stream using main context causing app to hang at certain places
1 parent 1dc7a35 commit 3df19d0

15 files changed

Lines changed: 682 additions & 345 deletions

File tree

backend/api/handlers/activities.go

Lines changed: 241 additions & 133 deletions
Large diffs are not rendered by default.

backend/api/handlers/activities_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package handlers
22

33
import (
4+
"bufio"
45
"context"
6+
"encoding/json"
7+
"io"
58
"net/http"
69
"net/http/httptest"
710
"testing"
@@ -14,6 +17,7 @@ import (
1417
"github.com/getarcaneapp/arcane/backend/v2/internal/database"
1518
"github.com/getarcaneapp/arcane/backend/v2/internal/models"
1619
"github.com/getarcaneapp/arcane/backend/v2/internal/services"
20+
"github.com/getarcaneapp/arcane/types/v2/activity"
1721
)
1822

1923
func setupActivityHandlerTestDBInternal(t *testing.T) *database.DB {
@@ -97,3 +101,145 @@ func TestActivityHandlerClearHistoryProxiesRemoteEnvironmentInternal(t *testing.
97101
require.NoError(t, err)
98102
require.EqualValues(t, 7, out.Body.Data.Deleted)
99103
}
104+
105+
// limitStreamTestDBToSingleConnInternal serializes DB access: the aggregated
106+
// stream queries from concurrent goroutines, and every extra pooled
107+
// connection to a :memory: SQLite database is a fresh empty database.
108+
func limitStreamTestDBToSingleConnInternal(t *testing.T, db *database.DB) {
109+
t.Helper()
110+
sqlDB, err := db.DB.DB()
111+
require.NoError(t, err)
112+
sqlDB.SetMaxOpenConns(1)
113+
}
114+
115+
func createStreamTestRemoteEnvironmentInternal(t *testing.T, db *database.DB, apiURL, token string) {
116+
t.Helper()
117+
now := time.Now()
118+
require.NoError(t, db.Create(&models.Environment{
119+
BaseModel: models.BaseModel{
120+
ID: "remote-1",
121+
CreatedAt: now,
122+
UpdatedAt: &now,
123+
},
124+
Name: "Remote",
125+
ApiUrl: apiURL,
126+
Status: string(models.EnvironmentStatusOnline),
127+
Enabled: true,
128+
AccessToken: &token,
129+
}).Error)
130+
}
131+
132+
// runStreamAllInternal drives streamAllActivitiesInternal through a pipe and
133+
// returns each decoded event to onEvent until it reports done or the stream
134+
// ends; remaining output is drained so a blocked encoder can always finish.
135+
func runStreamAllInternal(t *testing.T, ctx context.Context, cancel context.CancelFunc, handler *ActivityHandler, onEvent func(activity.StreamEvent) bool) {
136+
t.Helper()
137+
138+
pr, pw := io.Pipe()
139+
done := make(chan struct{})
140+
go func() {
141+
defer close(done)
142+
defer pw.Close()
143+
handler.streamAllActivitiesInternal(ctx, 50, json.NewEncoder(pw), func() {})
144+
}()
145+
146+
scanner := bufio.NewScanner(pr)
147+
for scanner.Scan() {
148+
var event activity.StreamEvent
149+
require.NoError(t, json.Unmarshal(scanner.Bytes(), &event))
150+
if onEvent(event) {
151+
cancel()
152+
break
153+
}
154+
}
155+
156+
go func() {
157+
_, _ = io.Copy(io.Discard, pr)
158+
}()
159+
select {
160+
case <-done:
161+
case <-time.After(5 * time.Second):
162+
t.Fatal("stream did not terminate after cancel")
163+
}
164+
}
165+
166+
func TestActivityHandlerStreamAllEmitsEnvironmentScopedEventsInternal(t *testing.T) {
167+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
168+
defer cancel()
169+
170+
db := setupActivityHandlerTestDBInternal(t)
171+
limitStreamTestDBToSingleConnInternal(t, db)
172+
settingsService, err := services.NewSettingsService(ctx, db)
173+
require.NoError(t, err)
174+
activityService := services.NewActivityService(db)
175+
176+
local, err := activityService.StartActivity(ctx, services.StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction})
177+
require.NoError(t, err)
178+
179+
token := "remote-token"
180+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181+
w.Header().Set("Content-Type", "application/json")
182+
_, _ = w.Write([]byte(`{"success":true,"data":[{"id":"remote-activity-1"}],"pagination":{"totalPages":1,"totalItems":1,"currentPage":1,"itemsPerPage":50}}`))
183+
}))
184+
defer server.Close()
185+
createStreamTestRemoteEnvironmentInternal(t, db, server.URL, token)
186+
187+
handler := &ActivityHandler{
188+
activityService: activityService,
189+
environmentService: services.NewEnvironmentService(db, server.Client(), nil, nil, settingsService, nil),
190+
}
191+
192+
var localSnapshot, remoteSnapshot bool
193+
runStreamAllInternal(t, ctx, cancel, handler, func(event activity.StreamEvent) bool {
194+
if event.Type == "snapshot" && event.EnvironmentID == "0" && len(event.Activities) == 1 {
195+
require.Equal(t, local.ID, event.Activities[0].ID)
196+
require.Equal(t, "0", event.Activities[0].SourceEnvironmentID)
197+
localSnapshot = true
198+
}
199+
if event.Type == "snapshot" && event.EnvironmentID == "remote-1" && len(event.Activities) == 1 {
200+
require.Equal(t, "remote-activity-1", event.Activities[0].ID)
201+
remoteSnapshot = true
202+
}
203+
return localSnapshot && remoteSnapshot
204+
})
205+
206+
require.True(t, localSnapshot)
207+
require.True(t, remoteSnapshot)
208+
}
209+
210+
func TestActivityHandlerStreamAllRemoteFailureEmitsErrorAndKeepsStreamingInternal(t *testing.T) {
211+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
212+
defer cancel()
213+
214+
db := setupActivityHandlerTestDBInternal(t)
215+
limitStreamTestDBToSingleConnInternal(t, db)
216+
settingsService, err := services.NewSettingsService(ctx, db)
217+
require.NoError(t, err)
218+
activityService := services.NewActivityService(db)
219+
220+
token := "remote-token"
221+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
222+
http.Error(w, "boom", http.StatusInternalServerError)
223+
}))
224+
defer server.Close()
225+
createStreamTestRemoteEnvironmentInternal(t, db, server.URL, token)
226+
227+
handler := &ActivityHandler{
228+
activityService: activityService,
229+
environmentService: services.NewEnvironmentService(db, server.Client(), nil, nil, settingsService, nil),
230+
}
231+
232+
var localSnapshot, remoteError bool
233+
runStreamAllInternal(t, ctx, cancel, handler, func(event activity.StreamEvent) bool {
234+
if event.Type == "snapshot" && event.EnvironmentID == "0" {
235+
localSnapshot = true
236+
}
237+
if event.Type == "error" && event.EnvironmentID == "remote-1" && event.Error != "" {
238+
remoteError = true
239+
}
240+
return localSnapshot && remoteError
241+
})
242+
243+
require.True(t, localSnapshot)
244+
require.True(t, remoteError)
245+
}

backend/internal/bootstrap/bootstrap.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"log/slog"
9+
"net"
910
"net/http"
1011
"os"
1112
"os/signal"
@@ -377,7 +378,14 @@ func runServicesInternal(appCtx context.Context, cfg *config.Config, router http
377378
httpHandler, grpcServer := configureTunnelServerInternal(appCtx, cfg, router, tunnelServer, listenAddr)
378379
httpHandler, protocols := configureHTTPProtocolsInternal(useTLS, httpHandler)
379380

380-
srv, err := newHTTPServerInternal(listenAddr, httpHandler, protocols, useTLS, edgeCfg)
381+
// Base context for all request contexts. Derived from Background, NOT
382+
// appCtx: appCtx carries the app-lifecycle marker value and inheriting it
383+
// would make every request context pass utils.IsAppLifecycleContext (see
384+
// pkg/projects/cmds.go).
385+
baseCtx, cancelBase := context.WithCancel(context.Background())
386+
defer cancelBase()
387+
388+
srv, err := newHTTPServerInternal(baseCtx, listenAddr, httpHandler, protocols, useTLS, edgeCfg) //nolint:contextcheck // baseCtx is deliberately not derived from appCtx, see comment above
381389
if err != nil {
382390
return err
383391
}
@@ -407,6 +415,12 @@ func runServicesInternal(appCtx context.Context, cfg *config.Config, router http
407415
slog.InfoContext(appCtx, "Context canceled")
408416
}
409417

418+
// http.Server.Shutdown waits for in-flight handlers but does not cancel
419+
// their request contexts; streaming handlers (activity streams, JSON-lines
420+
// progress) loop on ctx.Done() and would otherwise pin Shutdown until the
421+
// deadline below.
422+
cancelBase()
423+
410424
// Use background context for shutdown as appCtx is already canceled
411425
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
412426
defer shutdownCancel()
@@ -489,7 +503,7 @@ func configureHTTPProtocolsInternal(useTLS bool, handler http.Handler) (http.Han
489503
return handler, &protocols
490504
}
491505

492-
func newHTTPServerInternal(listenAddr string, handler http.Handler, protocols *http.Protocols, useTLS bool, edgeCfg *edge.Config) (*http.Server, error) {
506+
func newHTTPServerInternal(baseCtx context.Context, listenAddr string, handler http.Handler, protocols *http.Protocols, useTLS bool, edgeCfg *edge.Config) (*http.Server, error) {
493507
srv := &http.Server{
494508
Addr: listenAddr,
495509
Handler: handler,
@@ -501,6 +515,10 @@ func newHTTPServerInternal(listenAddr string, handler http.Handler, protocols *h
501515
// streaming endpoints (deploy/pull/build progress) need long-lived
502516
// connections.
503517
IdleTimeout: 120 * time.Second,
518+
// BaseContext is canceled right before Shutdown so long-lived
519+
// streaming request contexts unblock and graceful shutdown can
520+
// complete within its deadline.
521+
BaseContext: func(net.Listener) context.Context { return baseCtx },
504522
}
505523
if !useTLS {
506524
return srv, nil

backend/internal/bootstrap/bootstrap_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,47 @@ func TestHTTP2APIResponsesDoNotUseAPIGzipInternal(t *testing.T) {
212212
}
213213
}
214214

215+
func TestShutdownCancelsStreamingRequestContextsInternal(t *testing.T) {
216+
baseCtx, cancelBase := context.WithCancel(context.Background())
217+
defer cancelBase()
218+
219+
handlerEntered := make(chan struct{})
220+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
221+
w.Header().Set("Content-Type", "application/x-json-stream")
222+
w.WriteHeader(http.StatusOK)
223+
if f, ok := w.(http.Flusher); ok {
224+
f.Flush()
225+
}
226+
close(handlerEntered)
227+
// Streaming handlers block until their request context ends; Shutdown
228+
// alone never cancels it, so this models an open activity stream.
229+
<-r.Context().Done()
230+
})
231+
232+
srv, err := newHTTPServerInternal(baseCtx, "127.0.0.1:0", handler, nil, false, nil)
233+
require.NoError(t, err)
234+
235+
listener, err := net.Listen("tcp", "127.0.0.1:0")
236+
require.NoError(t, err)
237+
238+
errCh := make(chan error, 1)
239+
go func() { errCh <- srv.Serve(listener) }()
240+
241+
resp, err := http.Get("http://" + listener.Addr().String() + "/stream")
242+
require.NoError(t, err)
243+
defer func() { _ = resp.Body.Close() }()
244+
<-handlerEntered
245+
246+
cancelBase()
247+
248+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
249+
defer cancel()
250+
start := time.Now()
251+
require.NoError(t, srv.Shutdown(shutdownCtx))
252+
require.Less(t, time.Since(start), time.Second)
253+
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
254+
}
255+
215256
func TestPrepareServerTLSInternal_AgentModeSkipsManagerMTLSValidation(t *testing.T) {
216257
cfg := &config.Config{
217258
AgentMode: true,

backend/internal/middleware/environment_middleware_test.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,6 @@ func TestEnvironmentMiddleware_KeepsActivityEndpointsLocal(t *testing.T) {
157157
route: "/environments/:id/activities",
158158
path: "/api/environments/env-edge/activities?limit=50",
159159
},
160-
{
161-
name: "stream activities",
162-
method: http.MethodGet,
163-
route: "/environments/:id/activities/stream",
164-
path: "/api/environments/env-edge/activities/stream?limit=50",
165-
},
166160
}
167161

168162
for _, tt := range tests {

backend/pkg/libarcane/edge/commands.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ var commandRoutes = []commandRoute{
5151
{Method: http.MethodGet, PathPattern: "/api/environments/{id}/image-updates/summary", CommandName: "image_update.summary"},
5252

5353
{Method: http.MethodGet, PathPattern: "/api/environments/{id}/activities", CommandName: "activity.list"},
54-
{Method: http.MethodGet, PathPattern: "/api/environments/{id}/activities/stream", LocalOnly: true},
5554
{Method: http.MethodGet, PathPattern: "/api/environments/{id}/activities/{activityId}", CommandName: "activity.inspect"},
5655
{Method: http.MethodPost, PathPattern: "/api/environments/{id}/activities/{activityId}/cancel", CommandName: "activity.cancel"},
5756
{Method: http.MethodDelete, PathPattern: "/api/environments/{id}/activities/history", CommandName: "activity.history.clear"},

backend/pkg/libarcane/edge/commands_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ func TestResolveEdgeCommandName(t *testing.T) {
2929
{name: "activity inspect", method: "GET", path: "/api/environments/0/activities/activity-1", command: "activity.inspect", shouldHit: true},
3030
{name: "activity cancel", method: "POST", path: "/api/environments/0/activities/activity-1/cancel", command: "activity.cancel", shouldHit: true},
3131
{name: "activity history clear", method: "DELETE", path: "/api/environments/0/activities/history", command: "activity.history.clear", shouldHit: true},
32-
{name: "activity stream remains manager local", method: "GET", path: "/api/environments/0/activities/stream?limit=50", shouldHit: false},
3332
{name: "health", method: "HEAD", path: "/api/environments/0/system/health", command: "system.health", shouldHit: true},
3433
{name: "swarm node identity", method: "GET", path: "/api/swarm/node-identity", command: "swarm.node_identity", shouldHit: true},
3534
{name: "unknown", method: "PATCH", path: "/api/environments/0/containers", shouldHit: false},

backend/pkg/scheduler/auto_heal_job.go

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,14 @@ type AutoHealJob struct {
3434
mu sync.Mutex
3535
restarts map[string]*restartRecord
3636

37-
getDockerClient func() (*client.Client, error)
38-
listContainers func(ctx context.Context, dockerClient *client.Client) ([]container.Summary, error)
39-
inspectContainer func(ctx context.Context, dockerClient *client.Client, containerID string) (container.InspectResponse, error)
40-
restartContainer func(ctx context.Context, dockerClient *client.Client, containerID string) error
37+
selfIDOnce sync.Once
38+
selfID string
39+
40+
getDockerClient func() (*client.Client, error)
41+
listContainers func(ctx context.Context, dockerClient *client.Client) ([]container.Summary, error)
42+
inspectContainer func(ctx context.Context, dockerClient *client.Client, containerID string) (container.InspectResponse, error)
43+
restartContainer func(ctx context.Context, dockerClient *client.Client, containerID string) error
44+
getSelfContainerID func() (string, error)
4145
}
4246

4347
func NewAutoHealJob(
@@ -103,7 +107,8 @@ func (j *AutoHealJob) Run(ctx context.Context) {
103107
restartWindowMinutes := j.settingsService.GetIntSetting(ctx, "autoHealRestartWindow", 30)
104108
restartWindow := time.Duration(restartWindowMinutes) * time.Minute
105109

106-
candidates := j.filterCandidatesInternal(containers, excludedContainers)
110+
selfID := j.selfContainerIDInternal(ctx)
111+
candidates := j.filterCandidatesInternal(containers, excludedContainers, selfID)
107112

108113
g, groupCtx := errgroup.WithContext(ctx)
109114
g.SetLimit(autoHealInspectConcurrency)
@@ -118,9 +123,37 @@ func (j *AutoHealJob) Run(ctx context.Context) {
118123
_ = g.Wait()
119124
}
120125

121-
func (j *AutoHealJob) filterCandidatesInternal(containers []container.Summary, excludedContainers map[string]struct{}) []container.Summary {
126+
// selfContainerIDInternal resolves and caches the ID (full 64-char or short
127+
// prefix) of the container Arcane itself runs in. Returns "" when detection
128+
// fails (e.g. binary running directly on the host), disabling the guard.
129+
func (j *AutoHealJob) selfContainerIDInternal(ctx context.Context) string {
130+
j.selfIDOnce.Do(func() {
131+
detect := j.getSelfContainerID
132+
if detect == nil {
133+
detect = dockerutil.GetCurrentContainerID
134+
}
135+
id, err := detect()
136+
if err != nil {
137+
slog.DebugContext(ctx, "auto-heal: could not determine own container ID; self-protection disabled", "error", err)
138+
return
139+
}
140+
j.selfID = strings.ToLower(strings.TrimSpace(id))
141+
slog.InfoContext(ctx, "auto-heal: detected own container; it will never be auto-restarted", "container_id", j.selfID)
142+
})
143+
return j.selfID
144+
}
145+
146+
func (j *AutoHealJob) filterCandidatesInternal(containers []container.Summary, excludedContainers map[string]struct{}, selfID string) []container.Summary {
122147
candidates := make([]container.Summary, 0, len(containers))
123148
for _, c := range containers {
149+
// Never restart the container Arcane itself runs in: a slow or
150+
// mid-startup manager that trips its own healthcheck would otherwise
151+
// be restarted by its own auto-heal, in a loop. Prefix match because
152+
// hostname-based detection yields the short 12-char ID.
153+
if selfID != "" && strings.HasPrefix(strings.ToLower(c.ID), selfID) {
154+
continue
155+
}
156+
124157
if libarcane.IsInternalContainer(c.Labels) {
125158
continue
126159
}

backend/pkg/scheduler/auto_heal_job_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,30 @@ func newTestAutoHealJob() *AutoHealJob {
2222
}
2323
}
2424

25+
func TestAutoHeal_FilterCandidates_SkipsSelfContainer(t *testing.T) {
26+
job := newTestAutoHealJob()
27+
28+
selfFullID := "407163929c492b5c4b01a3981f5de4774c37aa8300bd214b4d62412a3dc56468"
29+
containers := []container.Summary{
30+
{ID: selfFullID, Names: []string{"/arcane"}},
31+
{ID: "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999", Names: []string{"/other"}},
32+
}
33+
34+
// Hostname-based detection yields the short 12-char ID.
35+
candidates := job.filterCandidatesInternal(containers, nil, selfFullID[:12])
36+
require.Len(t, candidates, 1)
37+
require.Equal(t, "/other", candidates[0].Names[0])
38+
39+
// cgroup/mountinfo-based detection yields the full ID.
40+
candidates = job.filterCandidatesInternal(containers, nil, selfFullID)
41+
require.Len(t, candidates, 1)
42+
require.Equal(t, "/other", candidates[0].Names[0])
43+
44+
// Outside Docker no self ID is detected and the guard is a no-op.
45+
candidates = job.filterCandidatesInternal(containers, nil, "")
46+
require.Len(t, candidates, 2)
47+
}
48+
2549
func TestAutoHeal_CanRestart_UnderLimit(t *testing.T) {
2650
job := newTestAutoHealJob()
2751

0 commit comments

Comments
 (0)