1package daemon
2
3import (
4 "testing"
5
6 "github.com/stretchr/testify/require"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/protocol"
10)
11
12// A wake can have passed coordinator scheduling before a link loss parks its
13// attachment. Resume deliberately reuses that attachment object, so pointer
14// identity alone must not let the old wake paint the new transport before its
15// Welcome frame.
16func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
17 pty, releasePTY := newBlockingPTY(t)
18 defer releasePTY()
19 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
20
21 oldTransport := &closeTrackingTransport{}
22 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTransport)
23 require.NoError(t, err)
24 token := ac.resumeToken
25 rc := sess.renderCoordinator()
26 require.NotNil(t, rc)
27 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
28
29 // Pause after dispatch but before the coordinator's normal wake callback
30 // reaches paint/sendMu. This is the precise stale-wake window.
31 originalWake := rc.opts.wake
32 wakeDispatched := make(chan struct{})
33 releaseWake := make(chan struct{})
34 rc.opts.wake = func(w renderWake) {
35 close(wakeDispatched)
36 <-releaseWake
37 originalWake(w)
38 }
39 wakeDone := make(chan struct{})
40 go func() {
41 d.invalidateRenderNow(sess, ac, true, "resume-wake-test")
42 close(wakeDone)
43 }()
44 <-wakeDispatched
45
46 d.clientGone(sess, ac, oldTransport, false)
47 newTransport := &closeTrackingTransport{}
48 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(protocol.IntentResume, sess.name, token), newTransport, domain.Size{Cols: 80, Rows: 24})
49 require.NoError(t, err)
50 require.True(t, ok)
51 require.Same(t, sess, resumedSess)
52 require.Same(t, ac, resumedAC)
53
54 close(releaseWake)
55 <-wakeDone
56 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
57 ac.sendMu.Lock()
58 staleWakeComposed := ac.render.cache.valid
59 ac.sendMu.Unlock()
60 require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
61
62 // The new attachment incarnation accepts the required paint only after its
63 // replacement transport has completed Welcome.
64 rc.opts.wake = originalWake
65 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
66 d.firstPaint(sess, ac)
67 require.NotEmpty(t, newTransport.Sends())
68}
69
70func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {
71 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
72 rc := d.attachCoordinator(sess, nil, ac, true)
73 oldLease := rc.attachmentLease(ac)
74 require.NotNil(t, oldLease)
75
76 originalWake := rc.opts.wake
77 wakeDispatched := make(chan struct{})
78 releaseWake := make(chan struct{})
79 rc.opts.wake = func(w renderWake) {
80 close(wakeDispatched)
81 <-releaseWake
82 originalWake(w)
83 }
84 wakeDone := make(chan struct{})
85 go func() {
86 d.invalidateRenderNow(sess, ac, true, "same-attachment-rebind")
87 close(wakeDone)
88 }()
89 <-wakeDispatched
90
91 oldGeneration := ac.lifecycle.generationValue()
92 result, err := d.transitionAttachment(attachmentTransitionRequest{
93 source: sess, target: sess, next: ac,
94
95 expectedTransport: ac.transportSnapshot(), ready: true,
96 })
97 require.NoError(t, err)
98 d.deferAttachmentTransitionCleanups(result)
99 require.Greater(t, ac.lifecycle.generationValue(), oldGeneration)
100 freshLease := rc.attachmentLease(ac)
101 require.NotSame(t, oldLease, freshLease)
102
103 close(releaseWake)
104 <-wakeDone
105 require.Empty(t, sends, "a queued wake from the prior generation must not paint the rebound transport")
106
107 rc.opts.wake = originalWake
108 d.invalidateRenderNow(sess, ac, true, "fresh-generation-fanout")
109 require.Greater(t, len(sends), 0, "the current generation must receive fanout output")
110}