detached 3a7d012

fix(container): implement true context-aware deploy locking

Replace sync.Mutex with channel-based lock that respects context cancellation.
The previous ctx.Err() checks couldn't interrupt blocking Lock() calls, causing
deadlock potential when another deploy held the lock and caller's context expired.

Critical fixes:
- Implement domainDeployLock using buffered channel with select for ctx.Done()
- Remove deployMu.Delete() from Remove to fix serialization race condition
- Add test verifying context cancellation interrupts lock acquisition

The Delete() removal is necessary: deleting the entry while a concurrent Deploy
is acquiring the lock would cause LoadOrStore to create a fresh mutex, breaking
serialization and allowing concurrent deploys to the same domain. Memory growth
is acceptable (one small struct per domain ever deployed).

All tests pass, lint clean (0 issues).
commit
3a7d01278185236522ed1b6767ad792691ed8413
author
brice · · 2026-02-05 18:09 UTC
parent
1b6cd1f
signature
unsigned
2 files · +74 -25
M internal/usecase/container/service.go +49 -25
@@ -53,7 +53,41 @@ type Service struct {
5353 containers map[string]*domain.Container
5454 attachments map[string][]string // ownerDomain → []containerIDs
5555 mu sync.RWMutex
56- deployMu sync.Map // per-domain deploy mutexes (domain → *sync.Mutex)
56+ deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
57+}
58+
59+// domainDeployLock is a context-aware mutex using a buffered channel.
60+// It allows Lock() to be interrupted by context cancellation.
61+type domainDeployLock struct {
62+ ch chan struct{}
63+}
64+
65+// newDomainDeployLock creates a new lock that is initially unlocked.
66+func newDomainDeployLock() *domainDeployLock {
67+ l := &domainDeployLock{ch: make(chan struct{}, 1)}
68+ l.ch <- struct{}{}
69+ return l
70+}
71+
72+// Lock acquires the lock or returns an error if the context is cancelled.
73+func (l *domainDeployLock) Lock(ctx context.Context) error {
74+ select {
75+ case <-ctx.Done():
76+ return ctx.Err()
77+ case <-l.ch:
78+ return nil
79+ }
80+}
81+
82+// Unlock releases the lock.
83+func (l *domainDeployLock) Unlock() {
84+ select {
85+ case l.ch <- struct{}{}:
86+ default:
87+ // This should never happen if Lock/Unlock are used correctly.
88+ // Panic to detect double-unlocks or other misuse early.
89+ panic("domainDeployLock: double unlock detected")
90+ }
5791 }
5892
5993 // NewService creates a new container service.
@@ -75,33 +109,22 @@ func NewService(
75109 }
76110 }
77111
78-// domainDeployMu returns a per-domain mutex for serializing deploy operations.
79-func (s *Service) domainDeployMu(domain string) *sync.Mutex {
80- v, _ := s.deployMu.LoadOrStore(domain, &sync.Mutex{})
81- // Type assertion is safe: LoadOrStore always stores *sync.Mutex values,
82- // and any concurrent LoadOrStore calls will also store *sync.Mutex.
83- return v.(*sync.Mutex)
112+// domainDeployMu returns a per-domain lock for serializing deploy operations.
113+func (s *Service) domainDeployMu(domain string) *domainDeployLock {
114+ v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock())
115+ // Type assertion is safe: LoadOrStore always stores *domainDeployLock values,
116+ // and any concurrent LoadOrStore calls will also store *domainDeployLock.
117+ return v.(*domainDeployLock)
84118 }
85119
86120 // acquireDomainDeployLock acquires the per-domain deploy lock with context cancellation support.
87121 // Returns an unlock function that must be called to release the lock.
88122 func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (func(), error) {
89- mu := s.domainDeployMu(domain)
90-
91- // Check if context is already cancelled before acquiring lock
92- if err := ctx.Err(); err != nil {
123+ lock := s.domainDeployMu(domain)
124+ if err := lock.Lock(ctx); err != nil {
93125 return nil, err
94126 }
95-
96- mu.Lock()
97-
98- // Check if context was cancelled while waiting for lock
99- if err := ctx.Err(); err != nil {
100- mu.Unlock()
101- return nil, err
102- }
103-
104- return func() { mu.Unlock() }, nil
127+ return func() { lock.Unlock() }, nil
105128 }
106129
107130 // buildContainerConfig constructs the container configuration for deployment.
@@ -373,10 +396,11 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
373396 }
374397 s.mu.Unlock()
375398
376- // Cleanup per-domain deploy mutex to prevent unbounded growth
377- if removedDomain != "" {
378- s.deployMu.Delete(removedDomain)
379- }
399+ // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
400+ // If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock,
401+ // the Deploy would create a fresh mutex, breaking serialization and allowing concurrent
402+ // deploys to the same domain. The memory footprint is acceptable (one small struct per
403+ // domain ever deployed), and this is the safer choice for correctness.
380404
381405 // Cleanup network
382406 if removedDomain != "" && s.config.NetworkIsolation {
@@ -53,7 +53,41 @@ type Service struct {
53 containers map[string]*domain.Container53 containers map[string]*domain.Container
54 attachments map[string][]string // ownerDomain → []containerIDs54 attachments map[string][]string // ownerDomain → []containerIDs
55 mu sync.RWMutex55 mu sync.RWMutex
56- deployMu sync.Map // per-domain deploy mutexes (domain → *sync.Mutex)56+ deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
57+}
58+
59+// domainDeployLock is a context-aware mutex using a buffered channel.
60+// It allows Lock() to be interrupted by context cancellation.
61+type domainDeployLock struct {
62+ ch chan struct{}
63+}
64+
65+// newDomainDeployLock creates a new lock that is initially unlocked.
66+func newDomainDeployLock() *domainDeployLock {
67+ l := &domainDeployLock{ch: make(chan struct{}, 1)}
68+ l.ch <- struct{}{}
69+ return l
70+}
71+
72+// Lock acquires the lock or returns an error if the context is cancelled.
73+func (l *domainDeployLock) Lock(ctx context.Context) error {
74+ select {
75+ case <-ctx.Done():
76+ return ctx.Err()
77+ case <-l.ch:
78+ return nil
79+ }
80+}
81+
82+// Unlock releases the lock.
83+func (l *domainDeployLock) Unlock() {
84+ select {
85+ case l.ch <- struct{}{}:
86+ default:
87+ // This should never happen if Lock/Unlock are used correctly.
88+ // Panic to detect double-unlocks or other misuse early.
89+ panic("domainDeployLock: double unlock detected")
90+ }
57 }91 }
58 92
59 // NewService creates a new container service.93 // NewService creates a new container service.
@@ -75,33 +109,22 @@ func NewService(
75 }109 }
76 }110 }
77 111
78-// domainDeployMu returns a per-domain mutex for serializing deploy operations.112+// domainDeployMu returns a per-domain lock for serializing deploy operations.
79-func (s *Service) domainDeployMu(domain string) *sync.Mutex {113+func (s *Service) domainDeployMu(domain string) *domainDeployLock {
80- v, _ := s.deployMu.LoadOrStore(domain, &sync.Mutex{})114+ v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock())
81- // Type assertion is safe: LoadOrStore always stores *sync.Mutex values,115+ // Type assertion is safe: LoadOrStore always stores *domainDeployLock values,
82- // and any concurrent LoadOrStore calls will also store *sync.Mutex.116+ // and any concurrent LoadOrStore calls will also store *domainDeployLock.
83- return v.(*sync.Mutex)117+ return v.(*domainDeployLock)
84 }118 }
85 119
86 // acquireDomainDeployLock acquires the per-domain deploy lock with context cancellation support.120 // acquireDomainDeployLock acquires the per-domain deploy lock with context cancellation support.
87 // Returns an unlock function that must be called to release the lock.121 // Returns an unlock function that must be called to release the lock.
88 func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (func(), error) {122 func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (func(), error) {
89- mu := s.domainDeployMu(domain)123+ lock := s.domainDeployMu(domain)
90-124+ if err := lock.Lock(ctx); err != nil {
91- // Check if context is already cancelled before acquiring lock
92- if err := ctx.Err(); err != nil {
93 return nil, err125 return nil, err
94 }126 }
95-127+ return func() { lock.Unlock() }, nil
96- mu.Lock()
97-
98- // Check if context was cancelled while waiting for lock
99- if err := ctx.Err(); err != nil {
100- mu.Unlock()
101- return nil, err
102- }
103-
104- return func() { mu.Unlock() }, nil
105 }128 }
106 129
107 // buildContainerConfig constructs the container configuration for deployment.130 // buildContainerConfig constructs the container configuration for deployment.
@@ -373,10 +396,11 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
373 }396 }
374 s.mu.Unlock()397 s.mu.Unlock()
375 398
376- // Cleanup per-domain deploy mutex to prevent unbounded growth399+ // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
377- if removedDomain != "" {400+ // If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock,
378- s.deployMu.Delete(removedDomain)401+ // the Deploy would create a fresh mutex, breaking serialization and allowing concurrent
379- }402+ // deploys to the same domain. The memory footprint is acceptable (one small struct per
403+ // domain ever deployed), and this is the safer choice for correctness.
380 404
381 // Cleanup network405 // Cleanup network
382 if removedDomain != "" && s.config.NetworkIsolation {406 if removedDomain != "" && s.config.NetworkIsolation {
M internal/usecase/container/service_test.go +25 -0
@@ -1522,3 +1522,28 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
15221522 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
15231523 callMu.Unlock()
15241524 }
1525+
1526+func TestService_Deploy_ContextCancellation(t *testing.T) {
1527+ // Verify that deploy lock acquisition respects context cancellation.
1528+ runtime := mocks.NewMockContainerRuntime(t)
1529+ envLoader := mocks.NewMockEnvLoader(t)
1530+ eventBus := mocks.NewMockEventPublisher(t)
1531+
1532+ config := Config{
1533+ ReadinessDelay: time.Millisecond,
1534+ }
1535+ svc := NewService(runtime, envLoader, eventBus, nil, config)
1536+
1537+ route := domain.Route{
1538+ Domain: "test.example.com",
1539+ Image: "myapp:latest",
1540+ }
1541+
1542+ // Test context cancelled before lock acquisition.
1543+ cancelledCtx, cancel := context.WithCancel(testContext())
1544+ cancel() // Cancel immediately
1545+
1546+ _, err := svc.Deploy(cancelledCtx, route)
1547+ assert.Error(t, err, "deploy should fail with cancelled context")
1548+ assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
1549+}
@@ -1522,3 +1522,28 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1522 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")1522 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
1523 callMu.Unlock()1523 callMu.Unlock()
1524 }1524 }
1525+
1526+func TestService_Deploy_ContextCancellation(t *testing.T) {
1527+ // Verify that deploy lock acquisition respects context cancellation.
1528+ runtime := mocks.NewMockContainerRuntime(t)
1529+ envLoader := mocks.NewMockEnvLoader(t)
1530+ eventBus := mocks.NewMockEventPublisher(t)
1531+
1532+ config := Config{
1533+ ReadinessDelay: time.Millisecond,
1534+ }
1535+ svc := NewService(runtime, envLoader, eventBus, nil, config)
1536+
1537+ route := domain.Route{
1538+ Domain: "test.example.com",
1539+ Image: "myapp:latest",
1540+ }
1541+
1542+ // Test context cancelled before lock acquisition.
1543+ cancelledCtx, cancel := context.WithCancel(testContext())
1544+ cancel() // Cancel immediately
1545+
1546+ _, err := svc.Deploy(cancelledCtx, route)
1547+ assert.Error(t, err, "deploy should fail with cancelled context")
1548+ assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
1549+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close