detached 6a6edad

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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"
44
55 type attachmentRole uint8
66
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+
716 const (
817 attachmentDetached attachmentRole = iota
918 attachmentActive
@@ -4,6 +4,15 @@ import "github.com/bnema/vev/internal/ports"
4 4
5 type attachmentRole uint85 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 = iota17 attachmentDetached attachmentRole = iota
9 attachmentActive18 attachmentActive
M internal/usecase/daemon/attachment_transition_publish.go +14 -9
@@ -287,19 +287,24 @@ func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
287287 // lockAttachmentSessions gives every two-session transition one stable order.
288288 // Session IDs are immutable and unique while their lifecycles are registered.
289289 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
293298 }
294- first, second := a, b
295- if first.core().id > second.core().id {
299+ first, second := aCore, bCore
300+ if first.id > second.id {
296301 first, second = second, first
297302 }
298- first.core().mu.Lock()
299- second.core().mu.Lock()
303+ first.mu.Lock()
304+ second.mu.Lock()
300305 return func() {
301- second.core().mu.Unlock()
302- first.core().mu.Unlock()
306+ second.mu.Unlock()
307+ first.mu.Unlock()
303308 }
304309 }
305310
@@ -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.Unlock292+ 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, b299+ first, second := aCore, bCore
295- if first.core().id > second.core().id {300+ if first.id > second.id {
296 first, second = second, first301 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 {
7373 stopped map[string]stoppedSession
7474 // creating reserves names while durable creation I/O runs without mu.
7575 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
7780 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
7881 // mark. It is guarded by mu and prevents a wall-clock regression from
7982 // reusing a lifecycle identity.
@@ -73,7 +73,10 @@ type Daemon struct {
73 stopped map[string]stoppedSession73 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 uint6476+ // 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-water80 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
78 // mark. It is guarded by mu and prevents a wall-clock regression from81 // 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 @@
11 package daemon
22
33 import (
4+ "time"
5+
46 "github.com/bnema/vev/internal/domain"
57 "github.com/bnema/vev/internal/usecase/picker"
68 )
@@ -18,6 +20,7 @@ type tabView struct {
1820 name string
1921 focusedTitle string
2022 attention bool
23+ attentionAt time.Time
2124 }
2225
2326 // sessionView is an immutable, value-only description of a session. It
@@ -38,6 +41,7 @@ type sessionView struct {
3841 hasAttention bool
3942 tabs []tabView
4043 cannotAcceptMoves bool
44+ expired bool
4145 }
4246
4347 // snapshotView reads session fields under s.mu and samples the independently
@@ -76,9 +80,10 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
7680 continue
7781 }
7882 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,
8287 }
8388 if opts.focusedTitles {
8489 entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle)
@@ -1,6 +1,8 @@
1 package daemon1 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 string20 name string
19 focusedTitle string21 focusedTitle string
20 attention bool22 attention bool
23+ attentionAt time.Time
21 }24 }
22 25
23 // sessionView is an immutable, value-only description of a session. It26 // sessionView is an immutable, value-only description of a session. It
@@ -38,6 +41,7 @@ type sessionView struct {
38 hasAttention bool41 hasAttention bool
39 tabs []tabView42 tabs []tabView
40 cannotAcceptMoves bool43 cannotAcceptMoves bool
44+ expired bool
41 }45 }
42 46
43 // snapshotView reads session fields under s.mu and samples the independently47 // snapshotView reads session fields under s.mu and samples the independently
@@ -76,9 +80,10 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
76 continue80 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)

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 12

Tags 11

↑↓ moveenter openesc close