feat(remote): move proxy lifecycle into the session core layer
proxy_link.go and proxy_session.go already call the proxy warm-timer and link-state helpers, but those definitions sat in a later layer, so this one did not build on its own. Move them down here. - add proxy_lifecycle.go with proxyWarmTimer, armProxyWarm, expireWarmProxy, updateProxyLinkState, updateProxyDisconnectedState, repaintProxyLifecycle, markProxyReplaced, and lifecycleDisplayName - add Daemon.proxyConstructions, the per-key handshake reservation map that openProxySession serializes remote IntentAttach dials through - add attachmentSessionCore and normalize lockAttachmentSessions with it so nil, typed-nil, and core-less attachments stop panicking - carry proxy lifecycle state into the view types via sessionView.expired and tabView.attentionAt
- commit
- 6a6edad5cec582702bc6d8c21c3bbd368a7f8a80
- author
- brice · · 2026-07-31 12:27 UTC
- parent
- ac27f72
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +332 -13
M internal/usecase/daemon/attachment_role.go +9 -0
| @@ -4,6 +4,15 @@ import "github.com/bnema/vev/internal/ports" | |||
| 4 | 4 | ||
| 5 | 5 | type attachmentRole uint8 | |
| 6 | 6 | ||
| 7 | + | // attachmentSessionCore normalizes nil interfaces, typed-nil implementations, | |
| 8 | + | // and implementations that deliberately expose no attachment core. | |
| 9 | + | func attachmentSessionCore(entry attachmentSession) *sessionCore { | |
| 10 | + | if entry == nil { | |
| 11 | + | return nil | |
| 12 | + | } | |
| 13 | + | return entry.core() | |
| 14 | + | } | |
| 15 | + | ||
| 7 | 16 | const ( | |
| 8 | 17 | attachmentDetached attachmentRole = iota | |
| 9 | 18 | attachmentActive | |
| @@ -4,6 +4,15 @@ import "github.com/bnema/vev/internal/ports" | |||||
| 4 | 4 | ||||
| 5 | type attachmentRole uint8 | 5 | type attachmentRole uint8 | ||
| 6 | 6 | ||||
| 7 | + | // attachmentSessionCore normalizes nil interfaces, typed-nil implementations, | |||
| 8 | + | // and implementations that deliberately expose no attachment core. | |||
| 9 | + | func attachmentSessionCore(entry attachmentSession) *sessionCore { | |||
| 10 | + | if entry == nil { | |||
| 11 | + | return nil | |||
| 12 | + | } | |||
| 13 | + | return entry.core() | |||
| 14 | + | } | |||
| 15 | + | ||||
| 7 | const ( | 16 | const ( | ||
| 8 | attachmentDetached attachmentRole = iota | 17 | attachmentDetached attachmentRole = iota | ||
| 9 | attachmentActive | 18 | attachmentActive | ||
M internal/usecase/daemon/attachment_transition_publish.go +14 -9
| @@ -287,19 +287,24 @@ func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |||
| 287 | 287 | // lockAttachmentSessions gives every two-session transition one stable order. | |
| 288 | 288 | // Session IDs are immutable and unique while their lifecycles are registered. | |
| 289 | 289 | func lockAttachmentSessions(a, b attachmentSession) func() { | |
| 290 | - | if a == b { | |
| 291 | - | a.core().mu.Lock() | |
| 292 | - | return a.core().mu.Unlock | |
| 290 | + | aCore := attachmentSessionCore(a) | |
| 291 | + | bCore := attachmentSessionCore(b) | |
| 292 | + | if aCore == nil || bCore == nil { | |
| 293 | + | return func() {} | |
| 294 | + | } | |
| 295 | + | if aCore == bCore { | |
| 296 | + | aCore.mu.Lock() | |
| 297 | + | return aCore.mu.Unlock | |
| 293 | 298 | } | |
| 294 | - | first, second := a, b | |
| 295 | - | if first.core().id > second.core().id { | |
| 299 | + | first, second := aCore, bCore | |
| 300 | + | if first.id > second.id { | |
| 296 | 301 | first, second = second, first | |
| 297 | 302 | } | |
| 298 | - | first.core().mu.Lock() | |
| 299 | - | second.core().mu.Lock() | |
| 303 | + | first.mu.Lock() | |
| 304 | + | second.mu.Lock() | |
| 300 | 305 | return func() { | |
| 301 | - | second.core().mu.Unlock() | |
| 302 | - | first.core().mu.Unlock() | |
| 306 | + | second.mu.Unlock() | |
| 307 | + | first.mu.Unlock() | |
| 303 | 308 | } | |
| 304 | 309 | } | |
| 305 | 310 | ||
| @@ -287,19 +287,24 @@ func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |||||
| 287 | // lockAttachmentSessions gives every two-session transition one stable order. | 287 | // lockAttachmentSessions gives every two-session transition one stable order. | ||
| 288 | // Session IDs are immutable and unique while their lifecycles are registered. | 288 | // Session IDs are immutable and unique while their lifecycles are registered. | ||
| 289 | func lockAttachmentSessions(a, b attachmentSession) func() { | 289 | func lockAttachmentSessions(a, b attachmentSession) func() { | ||
| 290 | - | if a == b { | 290 | + | aCore := attachmentSessionCore(a) |
| 291 | - | a.core().mu.Lock() | 291 | + | bCore := attachmentSessionCore(b) |
| 292 | - | return a.core().mu.Unlock | 292 | + | if aCore == nil || bCore == nil { |
| 293 | + | return func() {} | |||
| 294 | + | } | |||
| 295 | + | if aCore == bCore { | |||
| 296 | + | aCore.mu.Lock() | |||
| 297 | + | return aCore.mu.Unlock | |||
| 293 | } | 298 | } | ||
| 294 | - | first, second := a, b | 299 | + | first, second := aCore, bCore |
| 295 | - | if first.core().id > second.core().id { | 300 | + | if first.id > second.id { |
| 296 | first, second = second, first | 301 | first, second = second, first | ||
| 297 | } | 302 | } | ||
| 298 | - | first.core().mu.Lock() | 303 | + | first.mu.Lock() |
| 299 | - | second.core().mu.Lock() | 304 | + | second.mu.Lock() |
| 300 | return func() { | 305 | return func() { | ||
| 301 | - | second.core().mu.Unlock() | 306 | + | second.mu.Unlock() |
| 302 | - | first.core().mu.Unlock() | 307 | + | first.mu.Unlock() |
| 303 | } | 308 | } | ||
| 304 | } | 309 | } | ||
| 305 | 310 | ||||
M internal/usecase/daemon/daemon.go +4 -1
| @@ -73,7 +73,10 @@ type Daemon struct { | |||
| 73 | 73 | stopped map[string]stoppedSession | |
| 74 | 74 | // creating reserves names while durable creation I/O runs without mu. | |
| 75 | 75 | creating map[string]struct{} | |
| 76 | - | nextID uint64 | |
| 76 | + | // proxyConstructions serializes remote IntentAttach handshakes by structured | |
| 77 | + | // key. Entries are guarded by mu and never retain it across dial or link I/O. | |
| 78 | + | proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction | |
| 79 | + | nextID uint64 | |
| 77 | 80 | // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water | |
| 78 | 81 | // mark. It is guarded by mu and prevents a wall-clock regression from | |
| 79 | 82 | // reusing a lifecycle identity. | |
| @@ -73,7 +73,10 @@ type Daemon struct { | |||||
| 73 | stopped map[string]stoppedSession | 73 | stopped map[string]stoppedSession | ||
| 74 | // creating reserves names while durable creation I/O runs without mu. | 74 | // creating reserves names while durable creation I/O runs without mu. | ||
| 75 | creating map[string]struct{} | 75 | creating map[string]struct{} | ||
| 76 | - | nextID uint64 | 76 | + | // proxyConstructions serializes remote IntentAttach handshakes by structured |
| 77 | + | // key. Entries are guarded by mu and never retain it across dial or link I/O. | |||
| 78 | + | proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction | |||
| 79 | + | nextID uint64 | |||
| 77 | // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water | 80 | // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water | ||
| 78 | // mark. It is guarded by mu and prevents a wall-clock regression from | 81 | // mark. It is guarded by mu and prevents a wall-clock regression from | ||
| 79 | // reusing a lifecycle identity. | 82 | // reusing a lifecycle identity. | ||
A internal/usecase/daemon/proxy_lifecycle.go +297 -0
| @@ -0,0 +1,297 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const proxyWarmDuration = 5 * time.Minute | |
| 11 | + | ||
| 12 | + | // proxyWarmTimer is an exact lifecycle capability. Both pointer identity and | |
| 13 | + | // generation must still match before its callback may remove a registry entry. | |
| 14 | + | type proxyWarmTimer struct { | |
| 15 | + | generation uint64 | |
| 16 | + | timer ports.Timer | |
| 17 | + | cancel chan struct{} | |
| 18 | + | done chan struct{} | |
| 19 | + | cancelOnce sync.Once | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (t *proxyWarmTimer) stop() { | |
| 23 | + | if t == nil { | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | if t.timer != nil { | |
| 27 | + | t.timer.Stop() | |
| 28 | + | } | |
| 29 | + | t.cancelOnce.Do(func() { close(t.cancel) }) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // armProxyWarm reserves and publishes a fresh five-minute timer only while the | |
| 33 | + | // exact proxy remains registered and headless. Clock and Timer methods are | |
| 34 | + | // external operations and therefore run outside architecture locks. | |
| 35 | + | func (d *Daemon) armProxyWarm(p *proxySession) bool { | |
| 36 | + | if d == nil || p == nil || d.clock == nil { | |
| 37 | + | return false | |
| 38 | + | } | |
| 39 | + | d.mu.Lock() | |
| 40 | + | if d.sessions[p.id] != p || d.closing { | |
| 41 | + | d.mu.Unlock() | |
| 42 | + | return false | |
| 43 | + | } | |
| 44 | + | p.sessionCore.mu.Lock() | |
| 45 | + | if p.client != nil { | |
| 46 | + | p.sessionCore.mu.Unlock() | |
| 47 | + | d.mu.Unlock() | |
| 48 | + | return false | |
| 49 | + | } | |
| 50 | + | p.mu.Lock() | |
| 51 | + | p.generation++ | |
| 52 | + | generation := p.generation | |
| 53 | + | old := p.warm | |
| 54 | + | p.warm = nil | |
| 55 | + | p.mu.Unlock() | |
| 56 | + | p.sessionCore.mu.Unlock() | |
| 57 | + | d.mu.Unlock() | |
| 58 | + | old.stop() | |
| 59 | + | ||
| 60 | + | timer := d.clock.NewTimer(proxyWarmDuration) | |
| 61 | + | if timer == nil { | |
| 62 | + | return false | |
| 63 | + | } | |
| 64 | + | token := &proxyWarmTimer{ | |
| 65 | + | generation: generation, | |
| 66 | + | timer: timer, | |
| 67 | + | cancel: make(chan struct{}), | |
| 68 | + | done: make(chan struct{}), | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | d.mu.Lock() | |
| 72 | + | valid := d.sessions[p.id] == p && !d.closing | |
| 73 | + | if valid { | |
| 74 | + | p.sessionCore.mu.Lock() | |
| 75 | + | valid = p.client == nil | |
| 76 | + | if valid { | |
| 77 | + | p.mu.Lock() | |
| 78 | + | valid = p.generation == generation && p.warm == nil | |
| 79 | + | if valid { | |
| 80 | + | p.warm = token | |
| 81 | + | } | |
| 82 | + | p.mu.Unlock() | |
| 83 | + | } | |
| 84 | + | p.sessionCore.mu.Unlock() | |
| 85 | + | } | |
| 86 | + | d.mu.Unlock() | |
| 87 | + | if !valid { | |
| 88 | + | timer.Stop() | |
| 89 | + | close(token.done) | |
| 90 | + | return false | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | go func() { | |
| 94 | + | defer close(token.done) | |
| 95 | + | select { | |
| 96 | + | case <-timer.C(): | |
| 97 | + | d.expireWarmProxy(p, token) | |
| 98 | + | case <-token.cancel: | |
| 99 | + | } | |
| 100 | + | }() | |
| 101 | + | return true | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // expireWarmProxy removes only the exact dormant proxy pointer and lifecycle | |
| 105 | + | // generation. The canonical d.mu -> core.mu -> proxy.mu order is held only for | |
| 106 | + | // validation/publication; cancellation, coordinator teardown, transport close, | |
| 107 | + | // and daemon completion happen afterward. | |
| 108 | + | func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool { | |
| 109 | + | if d == nil || p == nil || token == nil { | |
| 110 | + | return false | |
| 111 | + | } | |
| 112 | + | d.mu.Lock() | |
| 113 | + | if d.sessions[p.id] != p { | |
| 114 | + | d.mu.Unlock() | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | p.sessionCore.mu.Lock() | |
| 118 | + | if p.client != nil { | |
| 119 | + | p.sessionCore.mu.Unlock() | |
| 120 | + | d.mu.Unlock() | |
| 121 | + | return false | |
| 122 | + | } | |
| 123 | + | p.mu.Lock() | |
| 124 | + | if p.warm != token || p.generation != token.generation { | |
| 125 | + | p.mu.Unlock() | |
| 126 | + | p.sessionCore.mu.Unlock() | |
| 127 | + | d.mu.Unlock() | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | p.warm = nil | |
| 131 | + | p.generation++ | |
| 132 | + | p.expired = true | |
| 133 | + | cancel := p.cancel | |
| 134 | + | transport := p.transport | |
| 135 | + | coordinator := p.coordinator.Load() | |
| 136 | + | p.mu.Unlock() | |
| 137 | + | if !d.unregisterSessionLocked(p) { | |
| 138 | + | p.sessionCore.mu.Unlock() | |
| 139 | + | d.mu.Unlock() | |
| 140 | + | return false | |
| 141 | + | } | |
| 142 | + | p.sessionCore.mu.Unlock() | |
| 143 | + | empty := len(d.sessions) == 0 | |
| 144 | + | if empty { | |
| 145 | + | d.closing = true | |
| 146 | + | } | |
| 147 | + | d.mu.Unlock() | |
| 148 | + | ||
| 149 | + | if cancel != nil { | |
| 150 | + | cancel() | |
| 151 | + | } | |
| 152 | + | if transport != nil { | |
| 153 | + | _ = transport.Close() | |
| 154 | + | } | |
| 155 | + | if coordinator != nil { | |
| 156 | + | coordinator.beginSessionTeardown().finish() | |
| 157 | + | coordinator.waitForTimerWorkers() | |
| 158 | + | } | |
| 159 | + | p.finish() | |
| 160 | + | if empty { | |
| 161 | + | d.doneOnce.Do(func() { close(d.done) }) | |
| 162 | + | } | |
| 163 | + | return true | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // updateProxyLinkState publishes one transport callback only if the exact proxy, | |
| 167 | + | // link generation, and transport remain current. Repaint is deliberately after | |
| 168 | + | // all architecture locks have been released. | |
| 169 | + | func (d *Daemon) updateProxyLinkState(p *proxySession, generation uint64, transport ports.Transport, state ports.LinkState) bool { | |
| 170 | + | if d == nil || p == nil || transport == nil { | |
| 171 | + | return false | |
| 172 | + | } | |
| 173 | + | d.mu.Lock() | |
| 174 | + | if d.sessions[p.id] != p { | |
| 175 | + | d.mu.Unlock() | |
| 176 | + | return false | |
| 177 | + | } | |
| 178 | + | p.sessionCore.mu.Lock() | |
| 179 | + | p.mu.Lock() | |
| 180 | + | current := !p.expired && p.linkGeneration == generation && p.transport == transport | |
| 181 | + | if current { | |
| 182 | + | p.linkState = state | |
| 183 | + | } | |
| 184 | + | ac := p.client | |
| 185 | + | p.mu.Unlock() | |
| 186 | + | p.sessionCore.mu.Unlock() | |
| 187 | + | d.mu.Unlock() | |
| 188 | + | if current && ac != nil { | |
| 189 | + | d.invalidateRender(p, ac, false, "proxy_lifecycle.go") | |
| 190 | + | } | |
| 191 | + | return current | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // updateProxyDisconnectedState publishes a state after the exact generation's | |
| 195 | + | // transport has retired. It is used while resume dialing is pending or failed. | |
| 196 | + | func (d *Daemon) updateProxyDisconnectedState(p *proxySession, generation uint64, state ports.LinkState) bool { | |
| 197 | + | if d == nil || p == nil { | |
| 198 | + | return false | |
| 199 | + | } | |
| 200 | + | d.mu.Lock() | |
| 201 | + | if d.sessions[p.id] != p { | |
| 202 | + | d.mu.Unlock() | |
| 203 | + | return false | |
| 204 | + | } | |
| 205 | + | p.sessionCore.mu.Lock() | |
| 206 | + | p.mu.Lock() | |
| 207 | + | current := !p.expired && p.linkGeneration == generation && p.transport == nil | |
| 208 | + | if current { | |
| 209 | + | p.linkState = state | |
| 210 | + | } | |
| 211 | + | ac := p.client | |
| 212 | + | p.mu.Unlock() | |
| 213 | + | p.sessionCore.mu.Unlock() | |
| 214 | + | d.mu.Unlock() | |
| 215 | + | if current && ac != nil { | |
| 216 | + | d.invalidateRender(p, ac, false, "proxy_lifecycle.go") | |
| 217 | + | } | |
| 218 | + | return current | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (d *Daemon) repaintProxyLifecycle(p *proxySession) { | |
| 222 | + | if d == nil || p == nil { | |
| 223 | + | return | |
| 224 | + | } | |
| 225 | + | d.mu.Lock() | |
| 226 | + | if d.sessions[p.id] != p { | |
| 227 | + | d.mu.Unlock() | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | p.sessionCore.mu.Lock() | |
| 231 | + | p.mu.Lock() | |
| 232 | + | current := !p.expired && p.transport != nil | |
| 233 | + | ac := p.client | |
| 234 | + | p.mu.Unlock() | |
| 235 | + | p.sessionCore.mu.Unlock() | |
| 236 | + | d.mu.Unlock() | |
| 237 | + | if current && ac != nil { | |
| 238 | + | d.invalidateRender(p, ac, false, "proxy_lifecycle.go") | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // markProxyReplaced makes ReasonReplaced terminal for this exact remote link. | |
| 243 | + | // Once published, no later state callback or resume attempt can revive it. An | |
| 244 | + | // already-armed dormant expiry remains the lifecycle capability for this exact | |
| 245 | + | // proxy and generation; replacement must not invalidate or clear that only path | |
| 246 | + | // out of the live registry. A dormant proxy without a timer is armed afterward, | |
| 247 | + | // outside every architecture lock. | |
| 248 | + | func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport ports.Transport) bool { | |
| 249 | + | if d == nil || p == nil || transport == nil { | |
| 250 | + | return false | |
| 251 | + | } | |
| 252 | + | d.mu.Lock() | |
| 253 | + | if d.sessions[p.id] != p { | |
| 254 | + | d.mu.Unlock() | |
| 255 | + | return false | |
| 256 | + | } | |
| 257 | + | p.sessionCore.mu.Lock() | |
| 258 | + | p.mu.Lock() | |
| 259 | + | current := p.linkGeneration == generation && p.transport == transport | |
| 260 | + | if current { | |
| 261 | + | p.expired = true | |
| 262 | + | p.linkState = ports.LinkStateDead | |
| 263 | + | p.generation++ | |
| 264 | + | if p.warm != nil { | |
| 265 | + | // Rekey the already-published exact timer instead of stopping it. This | |
| 266 | + | // preserves its deadline while invalidating older lifecycle observers. | |
| 267 | + | p.warm.generation = p.generation | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ac := p.client | |
| 271 | + | needsWarm := current && ac == nil && p.warm == nil | |
| 272 | + | p.mu.Unlock() | |
| 273 | + | p.sessionCore.mu.Unlock() | |
| 274 | + | d.mu.Unlock() | |
| 275 | + | if needsWarm { | |
| 276 | + | d.armProxyWarm(p) | |
| 277 | + | } | |
| 278 | + | if current && ac != nil { | |
| 279 | + | d.invalidateRender(p, ac, false, "proxy_lifecycle.go") | |
| 280 | + | } | |
| 281 | + | return current | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (p *proxySession) lifecycleDisplayName() string { | |
| 285 | + | if p == nil { | |
| 286 | + | return "" | |
| 287 | + | } | |
| 288 | + | p.mu.Lock() | |
| 289 | + | defer p.mu.Unlock() | |
| 290 | + | if p.expired { | |
| 291 | + | return p.name + " [expired]" | |
| 292 | + | } | |
| 293 | + | if p.linkState == ports.LinkStateConnected { | |
| 294 | + | return p.name | |
| 295 | + | } | |
| 296 | + | return p.name + " [" + p.linkState.String() + "]" | |
| 297 | + | } | |
| @@ -0,0 +1,297 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const proxyWarmDuration = 5 * time.Minute | |||
| 11 | + | ||||
| 12 | + | // proxyWarmTimer is an exact lifecycle capability. Both pointer identity and | |||
| 13 | + | // generation must still match before its callback may remove a registry entry. | |||
| 14 | + | type proxyWarmTimer struct { | |||
| 15 | + | generation uint64 | |||
| 16 | + | timer ports.Timer | |||
| 17 | + | cancel chan struct{} | |||
| 18 | + | done chan struct{} | |||
| 19 | + | cancelOnce sync.Once | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (t *proxyWarmTimer) stop() { | |||
| 23 | + | if t == nil { | |||
| 24 | + | return | |||
| 25 | + | } | |||
| 26 | + | if t.timer != nil { | |||
| 27 | + | t.timer.Stop() | |||
| 28 | + | } | |||
| 29 | + | t.cancelOnce.Do(func() { close(t.cancel) }) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // armProxyWarm reserves and publishes a fresh five-minute timer only while the | |||
| 33 | + | // exact proxy remains registered and headless. Clock and Timer methods are | |||
| 34 | + | // external operations and therefore run outside architecture locks. | |||
| 35 | + | func (d *Daemon) armProxyWarm(p *proxySession) bool { | |||
| 36 | + | if d == nil || p == nil || d.clock == nil { | |||
| 37 | + | return false | |||
| 38 | + | } | |||
| 39 | + | d.mu.Lock() | |||
| 40 | + | if d.sessions[p.id] != p || d.closing { | |||
| 41 | + | d.mu.Unlock() | |||
| 42 | + | return false | |||
| 43 | + | } | |||
| 44 | + | p.sessionCore.mu.Lock() | |||
| 45 | + | if p.client != nil { | |||
| 46 | + | p.sessionCore.mu.Unlock() | |||
| 47 | + | d.mu.Unlock() | |||
| 48 | + | return false | |||
| 49 | + | } | |||
| 50 | + | p.mu.Lock() | |||
| 51 | + | p.generation++ | |||
| 52 | + | generation := p.generation | |||
| 53 | + | old := p.warm | |||
| 54 | + | p.warm = nil | |||
| 55 | + | p.mu.Unlock() | |||
| 56 | + | p.sessionCore.mu.Unlock() | |||
| 57 | + | d.mu.Unlock() | |||
| 58 | + | old.stop() | |||
| 59 | + | ||||
| 60 | + | timer := d.clock.NewTimer(proxyWarmDuration) | |||
| 61 | + | if timer == nil { | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | token := &proxyWarmTimer{ | |||
| 65 | + | generation: generation, | |||
| 66 | + | timer: timer, | |||
| 67 | + | cancel: make(chan struct{}), | |||
| 68 | + | done: make(chan struct{}), | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | d.mu.Lock() | |||
| 72 | + | valid := d.sessions[p.id] == p && !d.closing | |||
| 73 | + | if valid { | |||
| 74 | + | p.sessionCore.mu.Lock() | |||
| 75 | + | valid = p.client == nil | |||
| 76 | + | if valid { | |||
| 77 | + | p.mu.Lock() | |||
| 78 | + | valid = p.generation == generation && p.warm == nil | |||
| 79 | + | if valid { | |||
| 80 | + | p.warm = token | |||
| 81 | + | } | |||
| 82 | + | p.mu.Unlock() | |||
| 83 | + | } | |||
| 84 | + | p.sessionCore.mu.Unlock() | |||
| 85 | + | } | |||
| 86 | + | d.mu.Unlock() | |||
| 87 | + | if !valid { | |||
| 88 | + | timer.Stop() | |||
| 89 | + | close(token.done) | |||
| 90 | + | return false | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | go func() { | |||
| 94 | + | defer close(token.done) | |||
| 95 | + | select { | |||
| 96 | + | case <-timer.C(): | |||
| 97 | + | d.expireWarmProxy(p, token) | |||
| 98 | + | case <-token.cancel: | |||
| 99 | + | } | |||
| 100 | + | }() | |||
| 101 | + | return true | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // expireWarmProxy removes only the exact dormant proxy pointer and lifecycle | |||
| 105 | + | // generation. The canonical d.mu -> core.mu -> proxy.mu order is held only for | |||
| 106 | + | // validation/publication; cancellation, coordinator teardown, transport close, | |||
| 107 | + | // and daemon completion happen afterward. | |||
| 108 | + | func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool { | |||
| 109 | + | if d == nil || p == nil || token == nil { | |||
| 110 | + | return false | |||
| 111 | + | } | |||
| 112 | + | d.mu.Lock() | |||
| 113 | + | if d.sessions[p.id] != p { | |||
| 114 | + | d.mu.Unlock() | |||
| 115 | + | return false | |||
| 116 | + | } | |||
| 117 | + | p.sessionCore.mu.Lock() | |||
| 118 | + | if p.client != nil { | |||
| 119 | + | p.sessionCore.mu.Unlock() | |||
| 120 | + | d.mu.Unlock() | |||
| 121 | + | return false | |||
| 122 | + | } | |||
| 123 | + | p.mu.Lock() | |||
| 124 | + | if p.warm != token || p.generation != token.generation { | |||
| 125 | + | p.mu.Unlock() | |||
| 126 | + | p.sessionCore.mu.Unlock() | |||
| 127 | + | d.mu.Unlock() | |||
| 128 | + | return false | |||
| 129 | + | } | |||
| 130 | + | p.warm = nil | |||
| 131 | + | p.generation++ | |||
| 132 | + | p.expired = true | |||
| 133 | + | cancel := p.cancel | |||
| 134 | + | transport := p.transport | |||
| 135 | + | coordinator := p.coordinator.Load() | |||
| 136 | + | p.mu.Unlock() | |||
| 137 | + | if !d.unregisterSessionLocked(p) { | |||
| 138 | + | p.sessionCore.mu.Unlock() | |||
| 139 | + | d.mu.Unlock() | |||
| 140 | + | return false | |||
| 141 | + | } | |||
| 142 | + | p.sessionCore.mu.Unlock() | |||
| 143 | + | empty := len(d.sessions) == 0 | |||
| 144 | + | if empty { | |||
| 145 | + | d.closing = true | |||
| 146 | + | } | |||
| 147 | + | d.mu.Unlock() | |||
| 148 | + | ||||
| 149 | + | if cancel != nil { | |||
| 150 | + | cancel() | |||
| 151 | + | } | |||
| 152 | + | if transport != nil { | |||
| 153 | + | _ = transport.Close() | |||
| 154 | + | } | |||
| 155 | + | if coordinator != nil { | |||
| 156 | + | coordinator.beginSessionTeardown().finish() | |||
| 157 | + | coordinator.waitForTimerWorkers() | |||
| 158 | + | } | |||
| 159 | + | p.finish() | |||
| 160 | + | if empty { | |||
| 161 | + | d.doneOnce.Do(func() { close(d.done) }) | |||
| 162 | + | } | |||
| 163 | + | return true | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // updateProxyLinkState publishes one transport callback only if the exact proxy, | |||
| 167 | + | // link generation, and transport remain current. Repaint is deliberately after | |||
| 168 | + | // all architecture locks have been released. | |||
| 169 | + | func (d *Daemon) updateProxyLinkState(p *proxySession, generation uint64, transport ports.Transport, state ports.LinkState) bool { | |||
| 170 | + | if d == nil || p == nil || transport == nil { | |||
| 171 | + | return false | |||
| 172 | + | } | |||
| 173 | + | d.mu.Lock() | |||
| 174 | + | if d.sessions[p.id] != p { | |||
| 175 | + | d.mu.Unlock() | |||
| 176 | + | return false | |||
| 177 | + | } | |||
| 178 | + | p.sessionCore.mu.Lock() | |||
| 179 | + | p.mu.Lock() | |||
| 180 | + | current := !p.expired && p.linkGeneration == generation && p.transport == transport | |||
| 181 | + | if current { | |||
| 182 | + | p.linkState = state | |||
| 183 | + | } | |||
| 184 | + | ac := p.client | |||
| 185 | + | p.mu.Unlock() | |||
| 186 | + | p.sessionCore.mu.Unlock() | |||
| 187 | + | d.mu.Unlock() | |||
| 188 | + | if current && ac != nil { | |||
| 189 | + | d.invalidateRender(p, ac, false, "proxy_lifecycle.go") | |||
| 190 | + | } | |||
| 191 | + | return current | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // updateProxyDisconnectedState publishes a state after the exact generation's | |||
| 195 | + | // transport has retired. It is used while resume dialing is pending or failed. | |||
| 196 | + | func (d *Daemon) updateProxyDisconnectedState(p *proxySession, generation uint64, state ports.LinkState) bool { | |||
| 197 | + | if d == nil || p == nil { | |||
| 198 | + | return false | |||
| 199 | + | } | |||
| 200 | + | d.mu.Lock() | |||
| 201 | + | if d.sessions[p.id] != p { | |||
| 202 | + | d.mu.Unlock() | |||
| 203 | + | return false | |||
| 204 | + | } | |||
| 205 | + | p.sessionCore.mu.Lock() | |||
| 206 | + | p.mu.Lock() | |||
| 207 | + | current := !p.expired && p.linkGeneration == generation && p.transport == nil | |||
| 208 | + | if current { | |||
| 209 | + | p.linkState = state | |||
| 210 | + | } | |||
| 211 | + | ac := p.client | |||
| 212 | + | p.mu.Unlock() | |||
| 213 | + | p.sessionCore.mu.Unlock() | |||
| 214 | + | d.mu.Unlock() | |||
| 215 | + | if current && ac != nil { | |||
| 216 | + | d.invalidateRender(p, ac, false, "proxy_lifecycle.go") | |||
| 217 | + | } | |||
| 218 | + | return current | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func (d *Daemon) repaintProxyLifecycle(p *proxySession) { | |||
| 222 | + | if d == nil || p == nil { | |||
| 223 | + | return | |||
| 224 | + | } | |||
| 225 | + | d.mu.Lock() | |||
| 226 | + | if d.sessions[p.id] != p { | |||
| 227 | + | d.mu.Unlock() | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | p.sessionCore.mu.Lock() | |||
| 231 | + | p.mu.Lock() | |||
| 232 | + | current := !p.expired && p.transport != nil | |||
| 233 | + | ac := p.client | |||
| 234 | + | p.mu.Unlock() | |||
| 235 | + | p.sessionCore.mu.Unlock() | |||
| 236 | + | d.mu.Unlock() | |||
| 237 | + | if current && ac != nil { | |||
| 238 | + | d.invalidateRender(p, ac, false, "proxy_lifecycle.go") | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // markProxyReplaced makes ReasonReplaced terminal for this exact remote link. | |||
| 243 | + | // Once published, no later state callback or resume attempt can revive it. An | |||
| 244 | + | // already-armed dormant expiry remains the lifecycle capability for this exact | |||
| 245 | + | // proxy and generation; replacement must not invalidate or clear that only path | |||
| 246 | + | // out of the live registry. A dormant proxy without a timer is armed afterward, | |||
| 247 | + | // outside every architecture lock. | |||
| 248 | + | func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport ports.Transport) bool { | |||
| 249 | + | if d == nil || p == nil || transport == nil { | |||
| 250 | + | return false | |||
| 251 | + | } | |||
| 252 | + | d.mu.Lock() | |||
| 253 | + | if d.sessions[p.id] != p { | |||
| 254 | + | d.mu.Unlock() | |||
| 255 | + | return false | |||
| 256 | + | } | |||
| 257 | + | p.sessionCore.mu.Lock() | |||
| 258 | + | p.mu.Lock() | |||
| 259 | + | current := p.linkGeneration == generation && p.transport == transport | |||
| 260 | + | if current { | |||
| 261 | + | p.expired = true | |||
| 262 | + | p.linkState = ports.LinkStateDead | |||
| 263 | + | p.generation++ | |||
| 264 | + | if p.warm != nil { | |||
| 265 | + | // Rekey the already-published exact timer instead of stopping it. This | |||
| 266 | + | // preserves its deadline while invalidating older lifecycle observers. | |||
| 267 | + | p.warm.generation = p.generation | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ac := p.client | |||
| 271 | + | needsWarm := current && ac == nil && p.warm == nil | |||
| 272 | + | p.mu.Unlock() | |||
| 273 | + | p.sessionCore.mu.Unlock() | |||
| 274 | + | d.mu.Unlock() | |||
| 275 | + | if needsWarm { | |||
| 276 | + | d.armProxyWarm(p) | |||
| 277 | + | } | |||
| 278 | + | if current && ac != nil { | |||
| 279 | + | d.invalidateRender(p, ac, false, "proxy_lifecycle.go") | |||
| 280 | + | } | |||
| 281 | + | return current | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func (p *proxySession) lifecycleDisplayName() string { | |||
| 285 | + | if p == nil { | |||
| 286 | + | return "" | |||
| 287 | + | } | |||
| 288 | + | p.mu.Lock() | |||
| 289 | + | defer p.mu.Unlock() | |||
| 290 | + | if p.expired { | |||
| 291 | + | return p.name + " [expired]" | |||
| 292 | + | } | |||
| 293 | + | if p.linkState == ports.LinkStateConnected { | |||
| 294 | + | return p.name | |||
| 295 | + | } | |||
| 296 | + | return p.name + " [" + p.linkState.String() + "]" | |||
| 297 | + | } | |||
M internal/usecase/daemon/session_view.go +8 -3
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/vev/internal/domain" | |
| 5 | 7 | "github.com/bnema/vev/internal/usecase/picker" | |
| 6 | 8 | ) | |
| @@ -18,6 +20,7 @@ type tabView struct { | |||
| 18 | 20 | name string | |
| 19 | 21 | focusedTitle string | |
| 20 | 22 | attention bool | |
| 23 | + | attentionAt time.Time | |
| 21 | 24 | } | |
| 22 | 25 | ||
| 23 | 26 | // sessionView is an immutable, value-only description of a session. It | |
| @@ -38,6 +41,7 @@ type sessionView struct { | |||
| 38 | 41 | hasAttention bool | |
| 39 | 42 | tabs []tabView | |
| 40 | 43 | cannotAcceptMoves bool | |
| 44 | + | expired bool | |
| 41 | 45 | } | |
| 42 | 46 | ||
| 43 | 47 | // snapshotView reads session fields under s.mu and samples the independently | |
| @@ -76,9 +80,10 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 76 | 80 | continue | |
| 77 | 81 | } | |
| 78 | 82 | entry := tabView{ | |
| 79 | - | id: domain.TabStableID(tb.stableID), | |
| 80 | - | name: tabDisplayName(tb, i), | |
| 81 | - | attention: tb.attention, | |
| 83 | + | id: domain.TabStableID(tb.stableID), | |
| 84 | + | name: tabDisplayName(tb, i), | |
| 85 | + | attention: tb.attention, | |
| 86 | + | attentionAt: tb.attentionAt, | |
| 82 | 87 | } | |
| 83 | 88 | if opts.focusedTitles { | |
| 84 | 89 | entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle) | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "time" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 5 | "github.com/bnema/vev/internal/usecase/picker" | 7 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 6 | ) | 8 | ) | ||
| @@ -18,6 +20,7 @@ type tabView struct { | |||||
| 18 | name string | 20 | name string | ||
| 19 | focusedTitle string | 21 | focusedTitle string | ||
| 20 | attention bool | 22 | attention bool | ||
| 23 | + | attentionAt time.Time | |||
| 21 | } | 24 | } | ||
| 22 | 25 | ||||
| 23 | // sessionView is an immutable, value-only description of a session. It | 26 | // sessionView is an immutable, value-only description of a session. It | ||
| @@ -38,6 +41,7 @@ type sessionView struct { | |||||
| 38 | hasAttention bool | 41 | hasAttention bool | ||
| 39 | tabs []tabView | 42 | tabs []tabView | ||
| 40 | cannotAcceptMoves bool | 43 | cannotAcceptMoves bool | ||
| 44 | + | expired bool | |||
| 41 | } | 45 | } | ||
| 42 | 46 | ||||
| 43 | // snapshotView reads session fields under s.mu and samples the independently | 47 | // snapshotView reads session fields under s.mu and samples the independently | ||
| @@ -76,9 +80,10 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||||
| 76 | continue | 80 | continue | ||
| 77 | } | 81 | } | ||
| 78 | entry := tabView{ | 82 | entry := tabView{ | ||
| 79 | - | id: domain.TabStableID(tb.stableID), | 83 | + | id: domain.TabStableID(tb.stableID), |
| 80 | - | name: tabDisplayName(tb, i), | 84 | + | name: tabDisplayName(tb, i), |
| 81 | - | attention: tb.attention, | 85 | + | attention: tb.attention, |
| 86 | + | attentionAt: tb.attentionAt, | |||
| 82 | } | 87 | } | ||
| 83 | if opts.focusedTitles { | 88 | if opts.focusedTitles { | ||
| 84 | entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle) | 89 | entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle) | ||