feat(daemon): retain detached remote views briefly
- commit
- 28680b34326562a3f553f611f8678c0e67f2c5fb
- author
- brice · · 2026-08-06 19:25 UTC
- parent
- d3ad7ec
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +265 -0
M internal/usecase/daemon/attachment_owner.go +4 -0
| @@ -68,6 +68,10 @@ type remoteView struct { | |||
| 68 | 68 | linkGeneration uint64 | |
| 69 | 69 | output remoteOutputState | |
| 70 | 70 | resetRequested bool | |
| 71 | + | // warm and warmGeneration fence the five-minute retention timer for an | |
| 72 | + | // unattached view. The timer is always stopped after view.mu is released. | |
| 73 | + | warm *remoteViewWarm | |
| 74 | + | warmGeneration uint64 | |
| 71 | 75 | } | |
| 72 | 76 | ||
| 73 | 77 | func (*remoteView) attachmentOwner() {} | |
| @@ -68,6 +68,10 @@ type remoteView struct { | |||||
| 68 | linkGeneration uint64 | 68 | linkGeneration uint64 | ||
| 69 | output remoteOutputState | 69 | output remoteOutputState | ||
| 70 | resetRequested bool | 70 | resetRequested bool | ||
| 71 | + | // warm and warmGeneration fence the five-minute retention timer for an | |||
| 72 | + | // unattached view. The timer is always stopped after view.mu is released. | |||
| 73 | + | warm *remoteViewWarm | |||
| 74 | + | warmGeneration uint64 | |||
| 71 | } | 75 | } | ||
| 72 | 76 | ||||
| 73 | func (*remoteView) attachmentOwner() {} | 77 | func (*remoteView) attachmentOwner() {} | ||
M internal/usecase/daemon/client_transport_errors.go +2 -0
| @@ -88,6 +88,7 @@ func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, tr | |||
| 88 | 88 | d.unregisterPreview(ac) | |
| 89 | 89 | if !explicit && d.parkAttachmentOwner(view, ac) { | |
| 90 | 90 | _ = ac.closeCapturedTransport(transport) | |
| 91 | + | d.parkRemoteViewWarm(view) | |
| 91 | 92 | return true | |
| 92 | 93 | } | |
| 93 | 94 | d.clearParkingInFlight(ac.resumeToken, ac) | |
| @@ -97,6 +98,7 @@ func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, tr | |||
| 97 | 98 | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | |
| 98 | 99 | } | |
| 99 | 100 | _ = ac.closeCapturedTransport(transport) | |
| 101 | + | d.parkRemoteViewWarm(view) | |
| 100 | 102 | return true | |
| 101 | 103 | } | |
| 102 | 104 | ||
| @@ -88,6 +88,7 @@ func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, tr | |||||
| 88 | d.unregisterPreview(ac) | 88 | d.unregisterPreview(ac) | ||
| 89 | if !explicit && d.parkAttachmentOwner(view, ac) { | 89 | if !explicit && d.parkAttachmentOwner(view, ac) { | ||
| 90 | _ = ac.closeCapturedTransport(transport) | 90 | _ = ac.closeCapturedTransport(transport) | ||
| 91 | + | d.parkRemoteViewWarm(view) | |||
| 91 | return true | 92 | return true | ||
| 92 | } | 93 | } | ||
| 93 | d.clearParkingInFlight(ac.resumeToken, ac) | 94 | d.clearParkingInFlight(ac.resumeToken, ac) | ||
| @@ -97,6 +98,7 @@ func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, tr | |||||
| 97 | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | 98 | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | ||
| 98 | } | 99 | } | ||
| 99 | _ = ac.closeCapturedTransport(transport) | 100 | _ = ac.closeCapturedTransport(transport) | ||
| 101 | + | d.parkRemoteViewWarm(view) | |||
| 100 | return true | 102 | return true | ||
| 101 | } | 103 | } | ||
| 102 | 104 | ||||
M internal/usecase/daemon/daemon.go +7 -0
| @@ -832,15 +832,22 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho | |||
| 832 | 832 | for _, cancel := range constructions { | |
| 833 | 833 | cancel() | |
| 834 | 834 | } | |
| 835 | + | remoteWarms := make([]*remoteViewWarm, 0, len(remoteViews)) | |
| 835 | 836 | remoteLinks := make([]*remoteLink, 0, len(remoteViews)) | |
| 836 | 837 | for _, view := range remoteViews { | |
| 837 | 838 | for _, ac := range view.close() { | |
| 838 | 839 | d.retireShutdownRemoteAttachment(view, ac, reason) | |
| 839 | 840 | } | |
| 841 | + | if warm := d.stopRemoteViewWarm(view); warm != nil { | |
| 842 | + | remoteWarms = append(remoteWarms, warm) | |
| 843 | + | } | |
| 840 | 844 | if link := d.stopRemoteViewLink(view); link != nil { | |
| 841 | 845 | remoteLinks = append(remoteLinks, link) | |
| 842 | 846 | } | |
| 843 | 847 | } | |
| 848 | + | for _, warm := range remoteWarms { | |
| 849 | + | warm.stop() | |
| 850 | + | } | |
| 844 | 851 | for _, link := range remoteLinks { | |
| 845 | 852 | <-link.done | |
| 846 | 853 | } | |
| @@ -832,15 +832,22 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho | |||||
| 832 | for _, cancel := range constructions { | 832 | for _, cancel := range constructions { | ||
| 833 | cancel() | 833 | cancel() | ||
| 834 | } | 834 | } | ||
| 835 | + | remoteWarms := make([]*remoteViewWarm, 0, len(remoteViews)) | |||
| 835 | remoteLinks := make([]*remoteLink, 0, len(remoteViews)) | 836 | remoteLinks := make([]*remoteLink, 0, len(remoteViews)) | ||
| 836 | for _, view := range remoteViews { | 837 | for _, view := range remoteViews { | ||
| 837 | for _, ac := range view.close() { | 838 | for _, ac := range view.close() { | ||
| 838 | d.retireShutdownRemoteAttachment(view, ac, reason) | 839 | d.retireShutdownRemoteAttachment(view, ac, reason) | ||
| 839 | } | 840 | } | ||
| 841 | + | if warm := d.stopRemoteViewWarm(view); warm != nil { | |||
| 842 | + | remoteWarms = append(remoteWarms, warm) | |||
| 843 | + | } | |||
| 840 | if link := d.stopRemoteViewLink(view); link != nil { | 844 | if link := d.stopRemoteViewLink(view); link != nil { | ||
| 841 | remoteLinks = append(remoteLinks, link) | 845 | remoteLinks = append(remoteLinks, link) | ||
| 842 | } | 846 | } | ||
| 843 | } | 847 | } | ||
| 848 | + | for _, warm := range remoteWarms { | |||
| 849 | + | warm.stop() | |||
| 850 | + | } | |||
| 844 | for _, link := range remoteLinks { | 851 | for _, link := range remoteLinks { | ||
| 845 | <-link.done | 852 | <-link.done | ||
| 846 | } | 853 | } | ||
M internal/usecase/daemon/remote_attachment_transition.go +2 -0
| @@ -61,6 +61,7 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target | |||
| 61 | 61 | source.mu.Unlock() | |
| 62 | 62 | d.notices.routingMu.Unlock() | |
| 63 | 63 | d.mu.Unlock() | |
| 64 | + | d.activateRemoteView(target) | |
| 64 | 65 | ||
| 65 | 66 | // The old coordinator is no longer a lease owner for this attachment. Its | |
| 66 | 67 | // cleanup runs after publication and outside architecture locks. | |
| @@ -162,5 +163,6 @@ func (d *Daemon) transitionFromRemoteView(token attachmentConnectionToken, sourc | |||
| 162 | 163 | // Record the reverse toggle while the attachment gate is still frozen, so a | |
| 163 | 164 | // concurrent handoff cannot observe the new local owner without its history. | |
| 164 | 165 | token.ac.recordPreviousOwner(source) | |
| 166 | + | d.parkRemoteViewWarm(source) | |
| 165 | 167 | return published, nil | |
| 166 | 168 | } | |
| @@ -61,6 +61,7 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target | |||||
| 61 | source.mu.Unlock() | 61 | source.mu.Unlock() | ||
| 62 | d.notices.routingMu.Unlock() | 62 | d.notices.routingMu.Unlock() | ||
| 63 | d.mu.Unlock() | 63 | d.mu.Unlock() | ||
| 64 | + | d.activateRemoteView(target) | |||
| 64 | 65 | ||||
| 65 | // The old coordinator is no longer a lease owner for this attachment. Its | 66 | // The old coordinator is no longer a lease owner for this attachment. Its | ||
| 66 | // cleanup runs after publication and outside architecture locks. | 67 | // cleanup runs after publication and outside architecture locks. | ||
| @@ -162,5 +163,6 @@ func (d *Daemon) transitionFromRemoteView(token attachmentConnectionToken, sourc | |||||
| 162 | // Record the reverse toggle while the attachment gate is still frozen, so a | 163 | // Record the reverse toggle while the attachment gate is still frozen, so a | ||
| 163 | // concurrent handoff cannot observe the new local owner without its history. | 164 | // concurrent handoff cannot observe the new local owner without its history. | ||
| 164 | token.ac.recordPreviousOwner(source) | 165 | token.ac.recordPreviousOwner(source) | ||
| 166 | + | d.parkRemoteViewWarm(source) | |||
| 165 | return published, nil | 167 | return published, nil | ||
| 166 | } | 168 | } | ||
M internal/usecase/daemon/remote_resume.go +1 -0
| @@ -150,6 +150,7 @@ func (d *Daemon) resumeRemoteAttachment(view *remoteView, ac *attachedClient, tr | |||
| 150 | 150 | ac.publishFrozenAttachmentCapability(published) | |
| 151 | 151 | view.mu.Unlock() | |
| 152 | 152 | d.mu.Unlock() | |
| 153 | + | d.activateRemoteView(view) | |
| 153 | 154 | return published, nil | |
| 154 | 155 | } | |
| 155 | 156 | ||
| @@ -150,6 +150,7 @@ func (d *Daemon) resumeRemoteAttachment(view *remoteView, ac *attachedClient, tr | |||||
| 150 | ac.publishFrozenAttachmentCapability(published) | 150 | ac.publishFrozenAttachmentCapability(published) | ||
| 151 | view.mu.Unlock() | 151 | view.mu.Unlock() | ||
| 152 | d.mu.Unlock() | 152 | d.mu.Unlock() | ||
| 153 | + | d.activateRemoteView(view) | |||
| 153 | return published, nil | 154 | return published, nil | ||
| 154 | } | 155 | } | ||
| 155 | 156 | ||||
A internal/usecase/daemon/remote_warm.go +162 -0
| @@ -0,0 +1,162 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const remoteViewWarmTTL = 5 * time.Minute | |
| 11 | + | ||
| 12 | + | // remoteViewWarm is a single generation-fenced retention timer. It owns no | |
| 13 | + | // view state: callers snapshot it while holding view.mu, then stop it after | |
| 14 | + | // every architecture lock is released. | |
| 15 | + | type remoteViewWarm struct { | |
| 16 | + | generation uint64 | |
| 17 | + | timer ports.Timer | |
| 18 | + | done chan struct{} | |
| 19 | + | doneOnce sync.Once | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (warm *remoteViewWarm) stop() { | |
| 23 | + | if warm == nil { | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | if warm.timer != nil { | |
| 27 | + | warm.timer.Stop() | |
| 28 | + | } | |
| 29 | + | warm.doneOnce.Do(func() { close(warm.done) }) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // parkRemoteViewWarm retains an exact registered remote view for a bounded | |
| 33 | + | // time after its final local attachment leaves. It never makes a remote view a | |
| 34 | + | // daemon-liveness root: daemon shutdown revokes this timer with the registry. | |
| 35 | + | func (d *Daemon) parkRemoteViewWarm(view *remoteView) { | |
| 36 | + | if d == nil || view == nil { | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | warm := &remoteViewWarm{ | |
| 40 | + | timer: d.clock.NewTimer(remoteViewWarmTTL), | |
| 41 | + | done: make(chan struct{}), | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | d.mu.Lock() | |
| 45 | + | if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) { | |
| 46 | + | d.mu.Unlock() | |
| 47 | + | warm.stop() | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | view.mu.Lock() | |
| 51 | + | if view.closed || len(view.attachments) != 0 { | |
| 52 | + | view.mu.Unlock() | |
| 53 | + | d.mu.Unlock() | |
| 54 | + | warm.stop() | |
| 55 | + | return | |
| 56 | + | } | |
| 57 | + | view.warmGeneration++ | |
| 58 | + | warm.generation = view.warmGeneration | |
| 59 | + | old := view.warm | |
| 60 | + | view.warm = warm | |
| 61 | + | view.mu.Unlock() | |
| 62 | + | d.mu.Unlock() | |
| 63 | + | ||
| 64 | + | old.stop() | |
| 65 | + | d.watchRemoteViewWarm(view, warm) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // activateRemoteView revokes a previously scheduled expiry when an attachment | |
| 69 | + | // becomes visible again. The exact-pointer/generation checks in the timer | |
| 70 | + | // callback make a racing stale expiry harmless. | |
| 71 | + | func (d *Daemon) activateRemoteView(view *remoteView) { | |
| 72 | + | if d == nil || view == nil { | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | d.mu.Lock() | |
| 76 | + | if !d.attachmentOwnerRegisteredByDaemonLocked(view) { | |
| 77 | + | d.mu.Unlock() | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | view.mu.Lock() | |
| 81 | + | warm := view.warm | |
| 82 | + | if warm != nil { | |
| 83 | + | view.warm = nil | |
| 84 | + | view.warmGeneration++ | |
| 85 | + | } | |
| 86 | + | view.mu.Unlock() | |
| 87 | + | d.mu.Unlock() | |
| 88 | + | warm.stop() | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (d *Daemon) watchRemoteViewWarm(view *remoteView, warm *remoteViewWarm) { | |
| 92 | + | if d == nil || view == nil || warm == nil || warm.timer == nil { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | go func() { | |
| 96 | + | select { | |
| 97 | + | case <-warm.timer.C(): | |
| 98 | + | d.expireRemoteViewWarm(view, warm) | |
| 99 | + | case <-warm.done: | |
| 100 | + | } | |
| 101 | + | }() | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // expireRemoteViewWarm removes only the exact, still-detached registered view. | |
| 105 | + | // It marks the candidate closed under registry/view locks, then interrupts and | |
| 106 | + | // joins its exact remote link without holding either lock. | |
| 107 | + | func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) { | |
| 108 | + | if d == nil || view == nil || warm == nil { | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | d.mu.Lock() | |
| 113 | + | if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) { | |
| 114 | + | d.mu.Unlock() | |
| 115 | + | return | |
| 116 | + | } | |
| 117 | + | view.mu.Lock() | |
| 118 | + | current := !view.closed && view.warm == warm && view.warmGeneration == warm.generation && len(view.attachments) == 0 | |
| 119 | + | if !current { | |
| 120 | + | view.mu.Unlock() | |
| 121 | + | d.mu.Unlock() | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | view.warm = nil | |
| 125 | + | view.warmGeneration++ | |
| 126 | + | view.closed = true | |
| 127 | + | link := view.link | |
| 128 | + | view.link = nil | |
| 129 | + | view.linkGeneration++ | |
| 130 | + | if link != nil { | |
| 131 | + | link.active = false | |
| 132 | + | } | |
| 133 | + | _ = d.unregisterRemoteViewLocked(view) | |
| 134 | + | view.mu.Unlock() | |
| 135 | + | d.mu.Unlock() | |
| 136 | + | ||
| 137 | + | warm.stop() | |
| 138 | + | if link == nil { | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | link.cancel() | |
| 142 | + | if link.transport != nil { | |
| 143 | + | _ = link.transport.Close() | |
| 144 | + | } | |
| 145 | + | <-link.done | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // stopRemoteViewWarm clears and returns only the current timer. Its caller is | |
| 149 | + | // responsible for stopping it after all architecture locks are released. | |
| 150 | + | func (d *Daemon) stopRemoteViewWarm(view *remoteView) *remoteViewWarm { | |
| 151 | + | if view == nil { | |
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | view.mu.Lock() | |
| 155 | + | warm := view.warm | |
| 156 | + | if warm != nil { | |
| 157 | + | view.warm = nil | |
| 158 | + | view.warmGeneration++ | |
| 159 | + | } | |
| 160 | + | view.mu.Unlock() | |
| 161 | + | return warm | |
| 162 | + | } | |
| @@ -0,0 +1,162 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const remoteViewWarmTTL = 5 * time.Minute | |||
| 11 | + | ||||
| 12 | + | // remoteViewWarm is a single generation-fenced retention timer. It owns no | |||
| 13 | + | // view state: callers snapshot it while holding view.mu, then stop it after | |||
| 14 | + | // every architecture lock is released. | |||
| 15 | + | type remoteViewWarm struct { | |||
| 16 | + | generation uint64 | |||
| 17 | + | timer ports.Timer | |||
| 18 | + | done chan struct{} | |||
| 19 | + | doneOnce sync.Once | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (warm *remoteViewWarm) stop() { | |||
| 23 | + | if warm == nil { | |||
| 24 | + | return | |||
| 25 | + | } | |||
| 26 | + | if warm.timer != nil { | |||
| 27 | + | warm.timer.Stop() | |||
| 28 | + | } | |||
| 29 | + | warm.doneOnce.Do(func() { close(warm.done) }) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // parkRemoteViewWarm retains an exact registered remote view for a bounded | |||
| 33 | + | // time after its final local attachment leaves. It never makes a remote view a | |||
| 34 | + | // daemon-liveness root: daemon shutdown revokes this timer with the registry. | |||
| 35 | + | func (d *Daemon) parkRemoteViewWarm(view *remoteView) { | |||
| 36 | + | if d == nil || view == nil { | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | warm := &remoteViewWarm{ | |||
| 40 | + | timer: d.clock.NewTimer(remoteViewWarmTTL), | |||
| 41 | + | done: make(chan struct{}), | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | d.mu.Lock() | |||
| 45 | + | if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) { | |||
| 46 | + | d.mu.Unlock() | |||
| 47 | + | warm.stop() | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | view.mu.Lock() | |||
| 51 | + | if view.closed || len(view.attachments) != 0 { | |||
| 52 | + | view.mu.Unlock() | |||
| 53 | + | d.mu.Unlock() | |||
| 54 | + | warm.stop() | |||
| 55 | + | return | |||
| 56 | + | } | |||
| 57 | + | view.warmGeneration++ | |||
| 58 | + | warm.generation = view.warmGeneration | |||
| 59 | + | old := view.warm | |||
| 60 | + | view.warm = warm | |||
| 61 | + | view.mu.Unlock() | |||
| 62 | + | d.mu.Unlock() | |||
| 63 | + | ||||
| 64 | + | old.stop() | |||
| 65 | + | d.watchRemoteViewWarm(view, warm) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // activateRemoteView revokes a previously scheduled expiry when an attachment | |||
| 69 | + | // becomes visible again. The exact-pointer/generation checks in the timer | |||
| 70 | + | // callback make a racing stale expiry harmless. | |||
| 71 | + | func (d *Daemon) activateRemoteView(view *remoteView) { | |||
| 72 | + | if d == nil || view == nil { | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | d.mu.Lock() | |||
| 76 | + | if !d.attachmentOwnerRegisteredByDaemonLocked(view) { | |||
| 77 | + | d.mu.Unlock() | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | view.mu.Lock() | |||
| 81 | + | warm := view.warm | |||
| 82 | + | if warm != nil { | |||
| 83 | + | view.warm = nil | |||
| 84 | + | view.warmGeneration++ | |||
| 85 | + | } | |||
| 86 | + | view.mu.Unlock() | |||
| 87 | + | d.mu.Unlock() | |||
| 88 | + | warm.stop() | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (d *Daemon) watchRemoteViewWarm(view *remoteView, warm *remoteViewWarm) { | |||
| 92 | + | if d == nil || view == nil || warm == nil || warm.timer == nil { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | go func() { | |||
| 96 | + | select { | |||
| 97 | + | case <-warm.timer.C(): | |||
| 98 | + | d.expireRemoteViewWarm(view, warm) | |||
| 99 | + | case <-warm.done: | |||
| 100 | + | } | |||
| 101 | + | }() | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // expireRemoteViewWarm removes only the exact, still-detached registered view. | |||
| 105 | + | // It marks the candidate closed under registry/view locks, then interrupts and | |||
| 106 | + | // joins its exact remote link without holding either lock. | |||
| 107 | + | func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) { | |||
| 108 | + | if d == nil || view == nil || warm == nil { | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | d.mu.Lock() | |||
| 113 | + | if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) { | |||
| 114 | + | d.mu.Unlock() | |||
| 115 | + | return | |||
| 116 | + | } | |||
| 117 | + | view.mu.Lock() | |||
| 118 | + | current := !view.closed && view.warm == warm && view.warmGeneration == warm.generation && len(view.attachments) == 0 | |||
| 119 | + | if !current { | |||
| 120 | + | view.mu.Unlock() | |||
| 121 | + | d.mu.Unlock() | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | view.warm = nil | |||
| 125 | + | view.warmGeneration++ | |||
| 126 | + | view.closed = true | |||
| 127 | + | link := view.link | |||
| 128 | + | view.link = nil | |||
| 129 | + | view.linkGeneration++ | |||
| 130 | + | if link != nil { | |||
| 131 | + | link.active = false | |||
| 132 | + | } | |||
| 133 | + | _ = d.unregisterRemoteViewLocked(view) | |||
| 134 | + | view.mu.Unlock() | |||
| 135 | + | d.mu.Unlock() | |||
| 136 | + | ||||
| 137 | + | warm.stop() | |||
| 138 | + | if link == nil { | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 141 | + | link.cancel() | |||
| 142 | + | if link.transport != nil { | |||
| 143 | + | _ = link.transport.Close() | |||
| 144 | + | } | |||
| 145 | + | <-link.done | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // stopRemoteViewWarm clears and returns only the current timer. Its caller is | |||
| 149 | + | // responsible for stopping it after all architecture locks are released. | |||
| 150 | + | func (d *Daemon) stopRemoteViewWarm(view *remoteView) *remoteViewWarm { | |||
| 151 | + | if view == nil { | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | view.mu.Lock() | |||
| 155 | + | warm := view.warm | |||
| 156 | + | if warm != nil { | |||
| 157 | + | view.warm = nil | |||
| 158 | + | view.warmGeneration++ | |||
| 159 | + | } | |||
| 160 | + | view.mu.Unlock() | |||
| 161 | + | return warm | |||
| 162 | + | } | |||
A internal/usecase/daemon/remote_warm_test.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRemoteViewWarmExpiryRemovesOnlyDetachedExactView(t *testing.T) { | |
| 13 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 14 | + | d := newTestDaemon(t, nil, clock) | |
| 15 | + | view := registerRemoteWarmTestView(t, d) | |
| 16 | + | ||
| 17 | + | d.parkRemoteViewWarm(view) | |
| 18 | + | warm, timer := remoteWarmTimer(t, clock, view) | |
| 19 | + | require.Equal(t, remoteViewWarmTTL, timer.duration) | |
| 20 | + | timer.ch <- time.Now() | |
| 21 | + | awaitTestCompletion(t, warm.done, "remote warm expiry did not complete") | |
| 22 | + | ||
| 23 | + | d.mu.Lock() | |
| 24 | + | require.Nil(t, d.remoteViewByKeyLocked(view.key)) | |
| 25 | + | d.mu.Unlock() | |
| 26 | + | view.mu.Lock() | |
| 27 | + | require.True(t, view.closed) | |
| 28 | + | require.Nil(t, view.link) | |
| 29 | + | view.mu.Unlock() | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestRemoteViewWarmActivationFencesStaleExpiry(t *testing.T) { | |
| 33 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 34 | + | d := newTestDaemon(t, nil, clock) | |
| 35 | + | view := registerRemoteWarmTestView(t, d) | |
| 36 | + | ||
| 37 | + | d.parkRemoteViewWarm(view) | |
| 38 | + | warm, timer := remoteWarmTimer(t, clock, view) | |
| 39 | + | attachment := &attachedClient{} | |
| 40 | + | view.mu.Lock() | |
| 41 | + | require.True(t, view.registerAttachmentLocked(attachment)) | |
| 42 | + | view.mu.Unlock() | |
| 43 | + | d.activateRemoteView(view) | |
| 44 | + | awaitTestCompletion(t, warm.done, "activating remote view did not cancel warm expiry") | |
| 45 | + | // A timer event already queued by the clock belongs to the canceled | |
| 46 | + | // generation and cannot remove the newly active remote view. | |
| 47 | + | timer.ch <- time.Now() | |
| 48 | + | ||
| 49 | + | d.mu.Lock() | |
| 50 | + | require.Same(t, view, d.remoteViewByKeyLocked(view.key)) | |
| 51 | + | d.mu.Unlock() | |
| 52 | + | view.mu.Lock() | |
| 53 | + | require.False(t, view.closed) | |
| 54 | + | view.mu.Unlock() | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestShutdownAllCancelsRemoteViewWarmTimer(t *testing.T) { | |
| 58 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 59 | + | d := newTestDaemon(t, nil, clock) | |
| 60 | + | view := registerRemoteWarmTestView(t, d) | |
| 61 | + | ||
| 62 | + | d.parkRemoteViewWarm(view) | |
| 63 | + | warm, _ := remoteWarmTimer(t, clock, view) | |
| 64 | + | d.shutdownAll(ports.ReasonServerShutdown) | |
| 65 | + | awaitTestCompletion(t, warm.done, "shutdown did not cancel remote warm timer") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func registerRemoteWarmTestView(t *testing.T, d *Daemon) *remoteView { | |
| 69 | + | t.Helper() | |
| 70 | + | key, err := remoteViewKeyForTarget(remoteLinkTestTarget()) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | view := &remoteView{key: key} | |
| 73 | + | d.mu.Lock() | |
| 74 | + | require.NoError(t, d.registerRemoteViewLocked(view)) | |
| 75 | + | d.mu.Unlock() | |
| 76 | + | return view | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func remoteWarmTimer(t *testing.T, clock *signalClock, view *remoteView) (*remoteViewWarm, *signalTimer) { | |
| 80 | + | t.Helper() | |
| 81 | + | timer := awaitTestValue(t, clock.timers, "remote warm timer was not created") | |
| 82 | + | view.mu.Lock() | |
| 83 | + | warm := view.warm | |
| 84 | + | view.mu.Unlock() | |
| 85 | + | require.NotNil(t, warm) | |
| 86 | + | return warm, timer | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestRemoteViewWarmExpiryRemovesOnlyDetachedExactView(t *testing.T) { | |||
| 13 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 14 | + | d := newTestDaemon(t, nil, clock) | |||
| 15 | + | view := registerRemoteWarmTestView(t, d) | |||
| 16 | + | ||||
| 17 | + | d.parkRemoteViewWarm(view) | |||
| 18 | + | warm, timer := remoteWarmTimer(t, clock, view) | |||
| 19 | + | require.Equal(t, remoteViewWarmTTL, timer.duration) | |||
| 20 | + | timer.ch <- time.Now() | |||
| 21 | + | awaitTestCompletion(t, warm.done, "remote warm expiry did not complete") | |||
| 22 | + | ||||
| 23 | + | d.mu.Lock() | |||
| 24 | + | require.Nil(t, d.remoteViewByKeyLocked(view.key)) | |||
| 25 | + | d.mu.Unlock() | |||
| 26 | + | view.mu.Lock() | |||
| 27 | + | require.True(t, view.closed) | |||
| 28 | + | require.Nil(t, view.link) | |||
| 29 | + | view.mu.Unlock() | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestRemoteViewWarmActivationFencesStaleExpiry(t *testing.T) { | |||
| 33 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 34 | + | d := newTestDaemon(t, nil, clock) | |||
| 35 | + | view := registerRemoteWarmTestView(t, d) | |||
| 36 | + | ||||
| 37 | + | d.parkRemoteViewWarm(view) | |||
| 38 | + | warm, timer := remoteWarmTimer(t, clock, view) | |||
| 39 | + | attachment := &attachedClient{} | |||
| 40 | + | view.mu.Lock() | |||
| 41 | + | require.True(t, view.registerAttachmentLocked(attachment)) | |||
| 42 | + | view.mu.Unlock() | |||
| 43 | + | d.activateRemoteView(view) | |||
| 44 | + | awaitTestCompletion(t, warm.done, "activating remote view did not cancel warm expiry") | |||
| 45 | + | // A timer event already queued by the clock belongs to the canceled | |||
| 46 | + | // generation and cannot remove the newly active remote view. | |||
| 47 | + | timer.ch <- time.Now() | |||
| 48 | + | ||||
| 49 | + | d.mu.Lock() | |||
| 50 | + | require.Same(t, view, d.remoteViewByKeyLocked(view.key)) | |||
| 51 | + | d.mu.Unlock() | |||
| 52 | + | view.mu.Lock() | |||
| 53 | + | require.False(t, view.closed) | |||
| 54 | + | view.mu.Unlock() | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestShutdownAllCancelsRemoteViewWarmTimer(t *testing.T) { | |||
| 58 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 59 | + | d := newTestDaemon(t, nil, clock) | |||
| 60 | + | view := registerRemoteWarmTestView(t, d) | |||
| 61 | + | ||||
| 62 | + | d.parkRemoteViewWarm(view) | |||
| 63 | + | warm, _ := remoteWarmTimer(t, clock, view) | |||
| 64 | + | d.shutdownAll(ports.ReasonServerShutdown) | |||
| 65 | + | awaitTestCompletion(t, warm.done, "shutdown did not cancel remote warm timer") | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func registerRemoteWarmTestView(t *testing.T, d *Daemon) *remoteView { | |||
| 69 | + | t.Helper() | |||
| 70 | + | key, err := remoteViewKeyForTarget(remoteLinkTestTarget()) | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | view := &remoteView{key: key} | |||
| 73 | + | d.mu.Lock() | |||
| 74 | + | require.NoError(t, d.registerRemoteViewLocked(view)) | |||
| 75 | + | d.mu.Unlock() | |||
| 76 | + | return view | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func remoteWarmTimer(t *testing.T, clock *signalClock, view *remoteView) (*remoteViewWarm, *signalTimer) { | |||
| 80 | + | t.Helper() | |||
| 81 | + | timer := awaitTestValue(t, clock.timers, "remote warm timer was not created") | |||
| 82 | + | view.mu.Lock() | |||
| 83 | + | warm := view.warm | |||
| 84 | + | view.mu.Unlock() | |||
| 85 | + | require.NotNil(t, warm) | |||
| 86 | + | return warm, timer | |||
| 87 | + | } | |||