refactor(client): delete unreachable picker and transition runners
- commit
- 5e20063eb82892b44f00edf9ecad36ff2249151a
- author
- brice · · 2026-09-22 21:13 UTC
- parent
- 45a9fcd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +25 -629
M internal/usecase/client/handshake.go +5 -152
| @@ -2,32 +2,22 @@ package client | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "sync" | |
| 7 | 6 | "time" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/vev/internal/ports" | |
| 10 | - | "github.com/bnema/vev/internal/protocol" | |
| 11 | 9 | ) | |
| 12 | 10 | ||
| 13 | - | var errHandshakeTimeout = errors.New("handshake timed out") | |
| 14 | - | ||
| 15 | 11 | type systemClock struct{} | |
| 16 | - | type systemTimer struct{ *time.Timer } | |
| 12 | + | type systemTimer struct{ timer *time.Timer } | |
| 17 | 13 | ||
| 18 | 14 | func (systemClock) Now() time.Time { return time.Now() } | |
| 19 | 15 | func (systemClock) NewTimer(delay time.Duration) ports.Timer { | |
| 20 | - | return systemTimer{Timer: time.NewTimer(delay)} | |
| 21 | - | } | |
| 22 | - | func (t systemTimer) C() <-chan time.Time { return t.Timer.C } | |
| 23 | - | func (t systemTimer) Reset(d time.Duration) bool { return t.Timer.Reset(d) } | |
| 24 | - | func (t systemTimer) Stop() bool { return t.Timer.Stop() } | |
| 25 | - | ||
| 26 | - | // newHandshakeContext owns one deadline for the complete outbound handshake. | |
| 27 | - | // The caller must stop it before entering the long-lived connection loop. | |
| 28 | - | func newHandshakeContext(parent context.Context, clock ports.Clock) (context.Context, <-chan struct{}, func()) { | |
| 29 | - | return newBoundedContext(parent, clock, protocol.HandshakeTimeout) | |
| 16 | + | return systemTimer{timer: time.NewTimer(delay)} | |
| 30 | 17 | } | |
| 18 | + | func (t systemTimer) C() <-chan time.Time { return t.timer.C } | |
| 19 | + | func (t systemTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) } | |
| 20 | + | func (t systemTimer) Stop() bool { return t.timer.Stop() } | |
| 31 | 21 | ||
| 32 | 22 | // newBoundedContext owns one deadline of the requested length. The caller must | |
| 33 | 23 | // invoke finish when the bounded operation ends. | |
| @@ -66,140 +56,3 @@ func newBoundedContext(parent context.Context, clock ports.Clock, timeout time.D | |||
| 66 | 56 | } | |
| 67 | 57 | return ctx, timedOut, finish | |
| 68 | 58 | } | |
| 69 | - | ||
| 70 | - | // watchHandshakeTransport closes a transport when the handshake context ends. | |
| 71 | - | // Transport.Close is required to interrupt blocked Send and Recv operations. | |
| 72 | - | func watchHandshakeTransport(ctx context.Context, transport ports.ClientConnection) func() { | |
| 73 | - | stop := make(chan struct{}) | |
| 74 | - | done := make(chan struct{}) | |
| 75 | - | go func() { | |
| 76 | - | defer close(done) | |
| 77 | - | select { | |
| 78 | - | case <-ctx.Done(): | |
| 79 | - | _ = transport.Close() | |
| 80 | - | case <-stop: | |
| 81 | - | } | |
| 82 | - | }() | |
| 83 | - | var once sync.Once | |
| 84 | - | return func() { | |
| 85 | - | once.Do(func() { | |
| 86 | - | close(stop) | |
| 87 | - | <-done | |
| 88 | - | }) | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | func boundedHandshakeOperationWithTransition(ctx context.Context, transport ports.ClientConnection, operation func() error, transition *transitionUI) error { | |
| 93 | - | if err := ctx.Err(); err != nil { | |
| 94 | - | _ = transport.Close() | |
| 95 | - | return err | |
| 96 | - | } | |
| 97 | - | completed := make(chan error, 1) | |
| 98 | - | go func() { completed <- operation() }() | |
| 99 | - | for { | |
| 100 | - | select { | |
| 101 | - | case err := <-completed: | |
| 102 | - | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 103 | - | _ = transport.Close() | |
| 104 | - | return ctxErr | |
| 105 | - | } | |
| 106 | - | return err | |
| 107 | - | case <-transition.tickC(): | |
| 108 | - | if err := transition.advance(); err != nil { | |
| 109 | - | _ = transport.Close() | |
| 110 | - | return err | |
| 111 | - | } | |
| 112 | - | case <-ctx.Done(): | |
| 113 | - | _ = transport.Close() | |
| 114 | - | // The result channel is buffered, so the operation can publish its | |
| 115 | - | // completion after Close without keeping this cancellation path stuck. | |
| 116 | - | return ctx.Err() | |
| 117 | - | } | |
| 118 | - | } | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | func boundedDial(ctx context.Context, dialer ports.ClientDialer) (ports.ClientConnection, error) { | |
| 122 | - | return boundedDialWithTransition(ctx, dialer, nil) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // retireSender bounds the wait for an attach loop's sender goroutine after its | |
| 126 | - | // context is canceled. A retained transport must not keep a wedged sender when | |
| 127 | - | // the cache takes over write ownership, so exceeding the shared handshake | |
| 128 | - | // budget closes the transport, which the ClientConnection contract requires to | |
| 129 | - | // unblock the send. The helper never waits past that budget. | |
| 130 | - | func retireSender(ctx context.Context, clock ports.Clock, transport ports.ClientConnection, done <-chan struct{}) { | |
| 131 | - | select { | |
| 132 | - | case <-done: | |
| 133 | - | return | |
| 134 | - | default: | |
| 135 | - | } | |
| 136 | - | bounded, _, finish := newHandshakeContext(ctx, clock) | |
| 137 | - | defer finish() | |
| 138 | - | select { | |
| 139 | - | case <-done: | |
| 140 | - | case <-bounded.Done(): | |
| 141 | - | if transport != nil { | |
| 142 | - | _ = transport.Close() | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | func boundedDialWithTransition(ctx context.Context, dialer ports.ClientDialer, transition *transitionUI) (ports.ClientConnection, error) { | |
| 148 | - | // The dial context only bounds startup. A successful carriage outlives the | |
| 149 | - | // handshake context; canceling that context after Welcome must not kill SSH. | |
| 150 | - | dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| 151 | - | type dialResult struct { | |
| 152 | - | transport ports.ClientConnection | |
| 153 | - | err error | |
| 154 | - | } | |
| 155 | - | result := make(chan dialResult, 1) | |
| 156 | - | go func() { | |
| 157 | - | transport, err := dialer.Dial(dialCtx) | |
| 158 | - | result <- dialResult{transport: transport, err: err} | |
| 159 | - | }() | |
| 160 | - | abandon := func() { | |
| 161 | - | cancel() | |
| 162 | - | go func() { | |
| 163 | - | late := <-result | |
| 164 | - | if late.transport != nil { | |
| 165 | - | _ = late.transport.Close() | |
| 166 | - | } | |
| 167 | - | }() | |
| 168 | - | } | |
| 169 | - | for { | |
| 170 | - | select { | |
| 171 | - | case result := <-result: | |
| 172 | - | if err := ctx.Err(); err != nil { | |
| 173 | - | cancel() | |
| 174 | - | if result.transport != nil { | |
| 175 | - | _ = result.transport.Close() | |
| 176 | - | } | |
| 177 | - | return nil, err | |
| 178 | - | } | |
| 179 | - | if result.err != nil { | |
| 180 | - | cancel() | |
| 181 | - | } | |
| 182 | - | return result.transport, result.err | |
| 183 | - | case <-transition.tickC(): | |
| 184 | - | if err := transition.advance(); err != nil { | |
| 185 | - | abandon() | |
| 186 | - | return nil, err | |
| 187 | - | } | |
| 188 | - | case <-ctx.Done(): | |
| 189 | - | abandon() | |
| 190 | - | return nil, ctx.Err() | |
| 191 | - | } | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error { | |
| 196 | - | select { | |
| 197 | - | case <-timedOut: | |
| 198 | - | return errors.Join(errHandshakeTimeout, context.DeadlineExceeded) | |
| 199 | - | default: | |
| 200 | - | } | |
| 201 | - | if err := parent.Err(); err != nil { | |
| 202 | - | return err | |
| 203 | - | } | |
| 204 | - | return fallback | |
| 205 | - | } | |
| @@ -2,32 +2,22 @@ package client | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "errors" | |||
| 6 | "sync" | 5 | "sync" | ||
| 7 | "time" | 6 | "time" | ||
| 8 | 7 | ||||
| 9 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 10 | - | "github.com/bnema/vev/internal/protocol" | |||
| 11 | ) | 9 | ) | ||
| 12 | 10 | ||||
| 13 | - | var errHandshakeTimeout = errors.New("handshake timed out") | |||
| 14 | - | ||||
| 15 | type systemClock struct{} | 11 | type systemClock struct{} | ||
| 16 | - | type systemTimer struct{ *time.Timer } | 12 | + | type systemTimer struct{ timer *time.Timer } |
| 17 | 13 | ||||
| 18 | func (systemClock) Now() time.Time { return time.Now() } | 14 | func (systemClock) Now() time.Time { return time.Now() } | ||
| 19 | func (systemClock) NewTimer(delay time.Duration) ports.Timer { | 15 | func (systemClock) NewTimer(delay time.Duration) ports.Timer { | ||
| 20 | - | return systemTimer{Timer: time.NewTimer(delay)} | 16 | + | return systemTimer{timer: time.NewTimer(delay)} |
| 21 | - | } | |||
| 22 | - | func (t systemTimer) C() <-chan time.Time { return t.Timer.C } | |||
| 23 | - | func (t systemTimer) Reset(d time.Duration) bool { return t.Timer.Reset(d) } | |||
| 24 | - | func (t systemTimer) Stop() bool { return t.Timer.Stop() } | |||
| 25 | - | ||||
| 26 | - | // newHandshakeContext owns one deadline for the complete outbound handshake. | |||
| 27 | - | // The caller must stop it before entering the long-lived connection loop. | |||
| 28 | - | func newHandshakeContext(parent context.Context, clock ports.Clock) (context.Context, <-chan struct{}, func()) { | |||
| 29 | - | return newBoundedContext(parent, clock, protocol.HandshakeTimeout) | |||
| 30 | } | 17 | } | ||
| 18 | + | func (t systemTimer) C() <-chan time.Time { return t.timer.C } | |||
| 19 | + | func (t systemTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) } | |||
| 20 | + | func (t systemTimer) Stop() bool { return t.timer.Stop() } | |||
| 31 | 21 | ||||
| 32 | // newBoundedContext owns one deadline of the requested length. The caller must | 22 | // newBoundedContext owns one deadline of the requested length. The caller must | ||
| 33 | // invoke finish when the bounded operation ends. | 23 | // invoke finish when the bounded operation ends. | ||
| @@ -66,140 +56,3 @@ func newBoundedContext(parent context.Context, clock ports.Clock, timeout time.D | |||||
| 66 | } | 56 | } | ||
| 67 | return ctx, timedOut, finish | 57 | return ctx, timedOut, finish | ||
| 68 | } | 58 | } | ||
| 69 | - | ||||
| 70 | - | // watchHandshakeTransport closes a transport when the handshake context ends. | |||
| 71 | - | // Transport.Close is required to interrupt blocked Send and Recv operations. | |||
| 72 | - | func watchHandshakeTransport(ctx context.Context, transport ports.ClientConnection) func() { | |||
| 73 | - | stop := make(chan struct{}) | |||
| 74 | - | done := make(chan struct{}) | |||
| 75 | - | go func() { | |||
| 76 | - | defer close(done) | |||
| 77 | - | select { | |||
| 78 | - | case <-ctx.Done(): | |||
| 79 | - | _ = transport.Close() | |||
| 80 | - | case <-stop: | |||
| 81 | - | } | |||
| 82 | - | }() | |||
| 83 | - | var once sync.Once | |||
| 84 | - | return func() { | |||
| 85 | - | once.Do(func() { | |||
| 86 | - | close(stop) | |||
| 87 | - | <-done | |||
| 88 | - | }) | |||
| 89 | - | } | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | func boundedHandshakeOperationWithTransition(ctx context.Context, transport ports.ClientConnection, operation func() error, transition *transitionUI) error { | |||
| 93 | - | if err := ctx.Err(); err != nil { | |||
| 94 | - | _ = transport.Close() | |||
| 95 | - | return err | |||
| 96 | - | } | |||
| 97 | - | completed := make(chan error, 1) | |||
| 98 | - | go func() { completed <- operation() }() | |||
| 99 | - | for { | |||
| 100 | - | select { | |||
| 101 | - | case err := <-completed: | |||
| 102 | - | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 103 | - | _ = transport.Close() | |||
| 104 | - | return ctxErr | |||
| 105 | - | } | |||
| 106 | - | return err | |||
| 107 | - | case <-transition.tickC(): | |||
| 108 | - | if err := transition.advance(); err != nil { | |||
| 109 | - | _ = transport.Close() | |||
| 110 | - | return err | |||
| 111 | - | } | |||
| 112 | - | case <-ctx.Done(): | |||
| 113 | - | _ = transport.Close() | |||
| 114 | - | // The result channel is buffered, so the operation can publish its | |||
| 115 | - | // completion after Close without keeping this cancellation path stuck. | |||
| 116 | - | return ctx.Err() | |||
| 117 | - | } | |||
| 118 | - | } | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | func boundedDial(ctx context.Context, dialer ports.ClientDialer) (ports.ClientConnection, error) { | |||
| 122 | - | return boundedDialWithTransition(ctx, dialer, nil) | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | // retireSender bounds the wait for an attach loop's sender goroutine after its | |||
| 126 | - | // context is canceled. A retained transport must not keep a wedged sender when | |||
| 127 | - | // the cache takes over write ownership, so exceeding the shared handshake | |||
| 128 | - | // budget closes the transport, which the ClientConnection contract requires to | |||
| 129 | - | // unblock the send. The helper never waits past that budget. | |||
| 130 | - | func retireSender(ctx context.Context, clock ports.Clock, transport ports.ClientConnection, done <-chan struct{}) { | |||
| 131 | - | select { | |||
| 132 | - | case <-done: | |||
| 133 | - | return | |||
| 134 | - | default: | |||
| 135 | - | } | |||
| 136 | - | bounded, _, finish := newHandshakeContext(ctx, clock) | |||
| 137 | - | defer finish() | |||
| 138 | - | select { | |||
| 139 | - | case <-done: | |||
| 140 | - | case <-bounded.Done(): | |||
| 141 | - | if transport != nil { | |||
| 142 | - | _ = transport.Close() | |||
| 143 | - | } | |||
| 144 | - | } | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | func boundedDialWithTransition(ctx context.Context, dialer ports.ClientDialer, transition *transitionUI) (ports.ClientConnection, error) { | |||
| 148 | - | // The dial context only bounds startup. A successful carriage outlives the | |||
| 149 | - | // handshake context; canceling that context after Welcome must not kill SSH. | |||
| 150 | - | dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |||
| 151 | - | type dialResult struct { | |||
| 152 | - | transport ports.ClientConnection | |||
| 153 | - | err error | |||
| 154 | - | } | |||
| 155 | - | result := make(chan dialResult, 1) | |||
| 156 | - | go func() { | |||
| 157 | - | transport, err := dialer.Dial(dialCtx) | |||
| 158 | - | result <- dialResult{transport: transport, err: err} | |||
| 159 | - | }() | |||
| 160 | - | abandon := func() { | |||
| 161 | - | cancel() | |||
| 162 | - | go func() { | |||
| 163 | - | late := <-result | |||
| 164 | - | if late.transport != nil { | |||
| 165 | - | _ = late.transport.Close() | |||
| 166 | - | } | |||
| 167 | - | }() | |||
| 168 | - | } | |||
| 169 | - | for { | |||
| 170 | - | select { | |||
| 171 | - | case result := <-result: | |||
| 172 | - | if err := ctx.Err(); err != nil { | |||
| 173 | - | cancel() | |||
| 174 | - | if result.transport != nil { | |||
| 175 | - | _ = result.transport.Close() | |||
| 176 | - | } | |||
| 177 | - | return nil, err | |||
| 178 | - | } | |||
| 179 | - | if result.err != nil { | |||
| 180 | - | cancel() | |||
| 181 | - | } | |||
| 182 | - | return result.transport, result.err | |||
| 183 | - | case <-transition.tickC(): | |||
| 184 | - | if err := transition.advance(); err != nil { | |||
| 185 | - | abandon() | |||
| 186 | - | return nil, err | |||
| 187 | - | } | |||
| 188 | - | case <-ctx.Done(): | |||
| 189 | - | abandon() | |||
| 190 | - | return nil, ctx.Err() | |||
| 191 | - | } | |||
| 192 | - | } | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error { | |||
| 196 | - | select { | |||
| 197 | - | case <-timedOut: | |||
| 198 | - | return errors.Join(errHandshakeTimeout, context.DeadlineExceeded) | |||
| 199 | - | default: | |||
| 200 | - | } | |||
| 201 | - | if err := parent.Err(); err != nil { | |||
| 202 | - | return err | |||
| 203 | - | } | |||
| 204 | - | return fallback | |||
| 205 | - | } | |||
M internal/usecase/client/handshake_test.go +9 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | type failedContextDialer struct { | |
| @@ -19,6 +20,14 @@ func (d *failedContextDialer) Dial(ctx context.Context) (ports.ClientConnection, | |||
| 19 | 20 | return nil, errors.New("dial failed") | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 23 | + | // boundedDial is retained only for this old timeout-contract test; the | |
| 24 | + | // autonomous supervisor opens through the broker instead. | |
| 25 | + | func boundedDial(ctx context.Context, dialer ports.ClientDialer) (ports.ClientConnection, error) { | |
| 26 | + | bounded, _, finish := newBoundedContext(ctx, systemClock{}, protocol.HandshakeTimeout) | |
| 27 | + | defer finish() | |
| 28 | + | return dialer.Dial(bounded) | |
| 29 | + | } | |
| 30 | + | ||
| 22 | 31 | func TestBoundedDialCancelsPrivateContextAfterFailure(t *testing.T) { | |
| 23 | 32 | dialer := &failedContextDialer{} | |
| 24 | 33 | _, err := boundedDial(context.Background(), dialer) | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 11 | + | "github.com/bnema/vev/internal/protocol" | |||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | type failedContextDialer struct { | 14 | type failedContextDialer struct { | ||
| @@ -19,6 +20,14 @@ func (d *failedContextDialer) Dial(ctx context.Context) (ports.ClientConnection, | |||||
| 19 | return nil, errors.New("dial failed") | 20 | return nil, errors.New("dial failed") | ||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 23 | + | // boundedDial is retained only for this old timeout-contract test; the | |||
| 24 | + | // autonomous supervisor opens through the broker instead. | |||
| 25 | + | func boundedDial(ctx context.Context, dialer ports.ClientDialer) (ports.ClientConnection, error) { | |||
| 26 | + | bounded, _, finish := newBoundedContext(ctx, systemClock{}, protocol.HandshakeTimeout) | |||
| 27 | + | defer finish() | |||
| 28 | + | return dialer.Dial(bounded) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 22 | func TestBoundedDialCancelsPrivateContextAfterFailure(t *testing.T) { | 31 | func TestBoundedDialCancelsPrivateContextAfterFailure(t *testing.T) { | ||
| 23 | dialer := &failedContextDialer{} | 32 | dialer := &failedContextDialer{} | ||
| 24 | _, err := boundedDial(context.Background(), dialer) | 33 | _, err := boundedDial(context.Background(), dialer) | ||
D internal/usecase/client/picker_control_box.go +0 -47
| @@ -1,47 +0,0 @@ | |||
| 1 | - | package client | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "sync" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/protocol" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | type pickerControlOutcome struct { | |
| 10 | - | snapshot *protocol.PickerSnapshot | |
| 11 | - | selection *protocol.PickerSelection | |
| 12 | - | target *protocol.AttachTarget | |
| 13 | - | err error | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | type pickerControlBox struct { | |
| 17 | - | mu sync.Mutex | |
| 18 | - | outcomes []pickerControlOutcome | |
| 19 | - | wake chan struct{} | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func newPickerControlBox() *pickerControlBox { return &pickerControlBox{wake: make(chan struct{}, 1)} } | |
| 23 | - | func (b *pickerControlBox) offer(outcome pickerControlOutcome) { | |
| 24 | - | b.mu.Lock() | |
| 25 | - | b.outcomes = append(b.outcomes, outcome) | |
| 26 | - | b.mu.Unlock() | |
| 27 | - | select { | |
| 28 | - | case b.wake <- struct{}{}: | |
| 29 | - | default: | |
| 30 | - | } | |
| 31 | - | } | |
| 32 | - | func (b *pickerControlBox) take() (pickerControlOutcome, bool) { | |
| 33 | - | b.mu.Lock() | |
| 34 | - | defer b.mu.Unlock() | |
| 35 | - | if len(b.outcomes) == 0 { | |
| 36 | - | return pickerControlOutcome{}, false | |
| 37 | - | } | |
| 38 | - | outcome := b.outcomes[0] | |
| 39 | - | b.outcomes = b.outcomes[1:] | |
| 40 | - | if len(b.outcomes) != 0 { | |
| 41 | - | select { | |
| 42 | - | case b.wake <- struct{}{}: | |
| 43 | - | default: | |
| 44 | - | } | |
| 45 | - | } | |
| 46 | - | return outcome, true | |
| 47 | - | } | |
| @@ -1,47 +0,0 @@ | |||||
| 1 | - | package client | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "sync" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/internal/protocol" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | type pickerControlOutcome struct { | |||
| 10 | - | snapshot *protocol.PickerSnapshot | |||
| 11 | - | selection *protocol.PickerSelection | |||
| 12 | - | target *protocol.AttachTarget | |||
| 13 | - | err error | |||
| 14 | - | } | |||
| 15 | - | ||||
| 16 | - | type pickerControlBox struct { | |||
| 17 | - | mu sync.Mutex | |||
| 18 | - | outcomes []pickerControlOutcome | |||
| 19 | - | wake chan struct{} | |||
| 20 | - | } | |||
| 21 | - | ||||
| 22 | - | func newPickerControlBox() *pickerControlBox { return &pickerControlBox{wake: make(chan struct{}, 1)} } | |||
| 23 | - | func (b *pickerControlBox) offer(outcome pickerControlOutcome) { | |||
| 24 | - | b.mu.Lock() | |||
| 25 | - | b.outcomes = append(b.outcomes, outcome) | |||
| 26 | - | b.mu.Unlock() | |||
| 27 | - | select { | |||
| 28 | - | case b.wake <- struct{}{}: | |||
| 29 | - | default: | |||
| 30 | - | } | |||
| 31 | - | } | |||
| 32 | - | func (b *pickerControlBox) take() (pickerControlOutcome, bool) { | |||
| 33 | - | b.mu.Lock() | |||
| 34 | - | defer b.mu.Unlock() | |||
| 35 | - | if len(b.outcomes) == 0 { | |||
| 36 | - | return pickerControlOutcome{}, false | |||
| 37 | - | } | |||
| 38 | - | outcome := b.outcomes[0] | |||
| 39 | - | b.outcomes = b.outcomes[1:] | |||
| 40 | - | if len(b.outcomes) != 0 { | |||
| 41 | - | select { | |||
| 42 | - | case b.wake <- struct{}{}: | |||
| 43 | - | default: | |||
| 44 | - | } | |||
| 45 | - | } | |||
| 46 | - | return outcome, true | |||
| 47 | - | } | |||
M internal/usecase/client/reconnect_toast.go +0 -34
| @@ -10,32 +10,6 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/usecase/ui" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | const reconnectToastMessage = "offline; retrying…" | |
| 14 | - | ||
| 15 | - | func reconnectStageMessage(stage reconnectStage) string { | |
| 16 | - | switch stage { | |
| 17 | - | case reconnectStageDegraded: | |
| 18 | - | return "connection degraded" | |
| 19 | - | case reconnectStageProbingUDP: | |
| 20 | - | return "probing UDP path" | |
| 21 | - | case reconnectStageSSH: | |
| 22 | - | return "offline; retrying… reconnecting through SSH" | |
| 23 | - | case reconnectStageOfflineRetrying: | |
| 24 | - | return reconnectToastMessage | |
| 25 | - | default: | |
| 26 | - | return reconnectToastMessage | |
| 27 | - | } | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | func drawReconnectToast(out io.Writer, size domain.Size) error { | |
| 31 | - | _, err := drawReconnectToastStage(out, size, reconnectStageOfflineRetrying) | |
| 32 | - | return err | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) { | |
| 36 | - | return drawClientToast(out, size, reconnectStageMessage(stage)) | |
| 37 | - | } | |
| 38 | - | ||
| 39 | 13 | // drawClientToast draws a client-local toast without changing terminal state. | |
| 40 | 14 | // The attach main loop owns both this write and the later daemon-frame | |
| 41 | 15 | // reconciliation; input pumps must only publish a request for it. | |
| @@ -47,18 +21,10 @@ func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Re | |||
| 47 | 21 | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | |
| 48 | 22 | } | |
| 49 | 23 | ||
| 50 | - | func reconnectToastBounds(size domain.Size) domain.Rect { | |
| 51 | - | return reconnectToastBoundsFor(size, reconnectToastMessage) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | 24 | func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect { | |
| 55 | 25 | return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter}) | |
| 56 | 26 | } | |
| 57 | 27 | ||
| 58 | - | func reconnectToastLines(bounds domain.Rect) []string { | |
| 59 | - | return reconnectToastLinesFor(bounds, reconnectToastMessage) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | 28 | func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | |
| 63 | 29 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 64 | 30 | return nil | |
| @@ -10,32 +10,6 @@ import ( | |||||
| 10 | "github.com/bnema/vev/internal/usecase/ui" | 10 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | const reconnectToastMessage = "offline; retrying…" | |||
| 14 | - | ||||
| 15 | - | func reconnectStageMessage(stage reconnectStage) string { | |||
| 16 | - | switch stage { | |||
| 17 | - | case reconnectStageDegraded: | |||
| 18 | - | return "connection degraded" | |||
| 19 | - | case reconnectStageProbingUDP: | |||
| 20 | - | return "probing UDP path" | |||
| 21 | - | case reconnectStageSSH: | |||
| 22 | - | return "offline; retrying… reconnecting through SSH" | |||
| 23 | - | case reconnectStageOfflineRetrying: | |||
| 24 | - | return reconnectToastMessage | |||
| 25 | - | default: | |||
| 26 | - | return reconnectToastMessage | |||
| 27 | - | } | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | func drawReconnectToast(out io.Writer, size domain.Size) error { | |||
| 31 | - | _, err := drawReconnectToastStage(out, size, reconnectStageOfflineRetrying) | |||
| 32 | - | return err | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) { | |||
| 36 | - | return drawClientToast(out, size, reconnectStageMessage(stage)) | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | // drawClientToast draws a client-local toast without changing terminal state. | 13 | // drawClientToast draws a client-local toast without changing terminal state. | ||
| 40 | // The attach main loop owns both this write and the later daemon-frame | 14 | // The attach main loop owns both this write and the later daemon-frame | ||
| 41 | // reconciliation; input pumps must only publish a request for it. | 15 | // reconciliation; input pumps must only publish a request for it. | ||
| @@ -47,18 +21,10 @@ func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Re | |||||
| 47 | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | 21 | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | ||
| 48 | } | 22 | } | ||
| 49 | 23 | ||||
| 50 | - | func reconnectToastBounds(size domain.Size) domain.Rect { | |||
| 51 | - | return reconnectToastBoundsFor(size, reconnectToastMessage) | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect { | 24 | func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect { | ||
| 55 | return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter}) | 25 | return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: domain.AnchorCenter}) | ||
| 56 | } | 26 | } | ||
| 57 | 27 | ||||
| 58 | - | func reconnectToastLines(bounds domain.Rect) []string { | |||
| 59 | - | return reconnectToastLinesFor(bounds, reconnectToastMessage) | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | 28 | func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | ||
| 63 | if bounds.Width <= 0 || bounds.Height <= 0 { | 29 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 64 | return nil | 30 | return nil | ||
M internal/usecase/client/runtime_primitives.go +0 -9
| @@ -56,12 +56,3 @@ func (l *foregroundSendLease) stop() { | |||
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | const stdinBufSize = 4096 | |
| 59 | - | ||
| 60 | - | type reconnectStage uint8 | |
| 61 | - | ||
| 62 | - | const ( | |
| 63 | - | reconnectStageDegraded reconnectStage = iota + 1 | |
| 64 | - | reconnectStageProbingUDP | |
| 65 | - | reconnectStageSSH | |
| 66 | - | reconnectStageOfflineRetrying | |
| 67 | - | ) | |
| @@ -56,12 +56,3 @@ func (l *foregroundSendLease) stop() { | |||||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | const stdinBufSize = 4096 | 58 | const stdinBufSize = 4096 | ||
| 59 | - | ||||
| 60 | - | type reconnectStage uint8 | |||
| 61 | - | ||||
| 62 | - | const ( | |||
| 63 | - | reconnectStageDegraded reconnectStage = iota + 1 | |||
| 64 | - | reconnectStageProbingUDP | |||
| 65 | - | reconnectStageSSH | |||
| 66 | - | reconnectStageOfflineRetrying | |||
| 67 | - | ) | |||
M internal/usecase/client/transition_ui.go +1 -116
| @@ -3,132 +3,17 @@ package client | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "fmt" | |
| 6 | - | "io" | |
| 7 | - | "time" | |
| 8 | 6 | ||
| 9 | 7 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/internal/ports" | |
| 11 | - | "github.com/bnema/vev/internal/protocol" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | const ( | |
| 15 | - | transitionSpinnerDelay = 120 * time.Millisecond | |
| 16 | - | transitionSpinnerInterval = 80 * time.Millisecond | |
| 17 | 8 | ) | |
| 18 | 9 | ||
| 19 | 10 | var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} | |
| 20 | 11 | ||
| 21 | - | func transitionMessage(target protocol.AttachTarget) string { | |
| 22 | - | origin := target.Endpoint | |
| 23 | - | label := target.Session | |
| 24 | - | if origin != "" { | |
| 25 | - | label = domain.RemoteSessionDisplay(target.Session, origin) | |
| 26 | - | } | |
| 27 | - | verb := "Switching to " | |
| 28 | - | if target.SessionTarget != nil && target.SessionTarget.Stopped { | |
| 29 | - | verb = "Starting " | |
| 30 | - | } | |
| 31 | - | return verb + label + "…" | |
| 32 | - | } | |
| 33 | - | ||
| 34 | 12 | // RenderTransitionNotice draws one connecting frame for the terminal owner. | |
| 35 | 13 | func RenderTransitionNotice(size domain.Size, frame int, message string) []byte { | |
| 36 | 14 | var out bytes.Buffer | |
| 37 | - | if _, err := drawTransitionToast(&out, size, frame, message); err != nil { | |
| 15 | + | if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message)); err != nil { | |
| 38 | 16 | return nil | |
| 39 | 17 | } | |
| 40 | 18 | return out.Bytes() | |
| 41 | 19 | } | |
| 42 | - | ||
| 43 | - | func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) { | |
| 44 | - | spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)] | |
| 45 | - | return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message)) | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // transitionUI owns the client-local handoff presentation. Every method is | |
| 49 | - | // called by the active attach loop, preserving the terminal's single writer | |
| 50 | - | // rule while network operations expose timer/resize events. | |
| 51 | - | type transitionUI struct { | |
| 52 | - | term ports.Terminal | |
| 53 | - | clock ports.Clock | |
| 54 | - | rawEntered *bool | |
| 55 | - | timer ports.Timer | |
| 56 | - | active bool | |
| 57 | - | showing bool | |
| 58 | - | frame int | |
| 59 | - | message string | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func newTransitionUI(term ports.Terminal, clock ports.Clock, rawEntered *bool) *transitionUI { | |
| 63 | - | return &transitionUI{term: term, clock: clock, rawEntered: rawEntered} | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func (u *transitionUI) start(target protocol.AttachTarget) { | |
| 67 | - | if u == nil { | |
| 68 | - | return | |
| 69 | - | } | |
| 70 | - | u.stop() | |
| 71 | - | u.active = true | |
| 72 | - | u.frame = 0 | |
| 73 | - | u.message = transitionMessage(target) | |
| 74 | - | if u.clock != nil { | |
| 75 | - | u.timer = u.clock.NewTimer(transitionSpinnerDelay) | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | func (u *transitionUI) tickC() <-chan time.Time { | |
| 80 | - | if u == nil || !u.active || u.timer == nil { | |
| 81 | - | return nil | |
| 82 | - | } | |
| 83 | - | return u.timer.C() | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | func (u *transitionUI) advance() error { | |
| 87 | - | if u == nil || !u.active { | |
| 88 | - | return nil | |
| 89 | - | } | |
| 90 | - | geometry, err := u.term.Geometry() | |
| 91 | - | if err != nil { | |
| 92 | - | return fmt.Errorf("reading terminal geometry for transition toast: %w", err) | |
| 93 | - | } | |
| 94 | - | if err := u.redraw(geometry.Size); err != nil { | |
| 95 | - | return err | |
| 96 | - | } | |
| 97 | - | u.frame = (u.frame + 1) % len(transitionSpinnerFrames) | |
| 98 | - | if u.timer != nil { | |
| 99 | - | u.timer.Reset(transitionSpinnerInterval) | |
| 100 | - | } | |
| 101 | - | return nil | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | func (u *transitionUI) redraw(size domain.Size) error { | |
| 105 | - | if u == nil || !u.active || u.rawEntered == nil || !*u.rawEntered { | |
| 106 | - | return nil | |
| 107 | - | } | |
| 108 | - | bounds, err := drawTransitionToast(u.term.Out(), size, u.frame, u.message) | |
| 109 | - | if err != nil { | |
| 110 | - | return fmt.Errorf("drawing transition toast: %w", err) | |
| 111 | - | } | |
| 112 | - | if bounds.Width == 0 || bounds.Height == 0 { | |
| 113 | - | return nil | |
| 114 | - | } | |
| 115 | - | if err := u.term.Flush(); err != nil { | |
| 116 | - | return fmt.Errorf("flushing transition toast: %w", err) | |
| 117 | - | } | |
| 118 | - | u.showing = true | |
| 119 | - | return nil | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | func (u *transitionUI) stop() { | |
| 123 | - | if u == nil { | |
| 124 | - | return | |
| 125 | - | } | |
| 126 | - | if u.timer != nil { | |
| 127 | - | u.timer.Stop() | |
| 128 | - | u.timer = nil | |
| 129 | - | } | |
| 130 | - | u.active = false | |
| 131 | - | u.showing = false | |
| 132 | - | u.frame = 0 | |
| 133 | - | u.message = "" | |
| 134 | - | } | |
| @@ -3,132 +3,17 @@ package client | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "io" | |||
| 7 | - | "time" | |||
| 8 | 6 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 10 | - | "github.com/bnema/vev/internal/ports" | |||
| 11 | - | "github.com/bnema/vev/internal/protocol" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | const ( | |||
| 15 | - | transitionSpinnerDelay = 120 * time.Millisecond | |||
| 16 | - | transitionSpinnerInterval = 80 * time.Millisecond | |||
| 17 | ) | 8 | ) | ||
| 18 | 9 | ||||
| 19 | var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} | 10 | var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} | ||
| 20 | 11 | ||||
| 21 | - | func transitionMessage(target protocol.AttachTarget) string { | |||
| 22 | - | origin := target.Endpoint | |||
| 23 | - | label := target.Session | |||
| 24 | - | if origin != "" { | |||
| 25 | - | label = domain.RemoteSessionDisplay(target.Session, origin) | |||
| 26 | - | } | |||
| 27 | - | verb := "Switching to " | |||
| 28 | - | if target.SessionTarget != nil && target.SessionTarget.Stopped { | |||
| 29 | - | verb = "Starting " | |||
| 30 | - | } | |||
| 31 | - | return verb + label + "…" | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | // RenderTransitionNotice draws one connecting frame for the terminal owner. | 12 | // RenderTransitionNotice draws one connecting frame for the terminal owner. | ||
| 35 | func RenderTransitionNotice(size domain.Size, frame int, message string) []byte { | 13 | func RenderTransitionNotice(size domain.Size, frame int, message string) []byte { | ||
| 36 | var out bytes.Buffer | 14 | var out bytes.Buffer | ||
| 37 | - | if _, err := drawTransitionToast(&out, size, frame, message); err != nil { | 15 | + | if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message)); err != nil { |
| 38 | return nil | 16 | return nil | ||
| 39 | } | 17 | } | ||
| 40 | return out.Bytes() | 18 | return out.Bytes() | ||
| 41 | } | 19 | } | ||
| 42 | - | ||||
| 43 | - | func drawTransitionToast(out io.Writer, size domain.Size, frame int, message string) (domain.Rect, error) { | |||
| 44 | - | spinner := transitionSpinnerFrames[frame%len(transitionSpinnerFrames)] | |||
| 45 | - | return drawClientToast(out, size, fmt.Sprintf("%c %s", spinner, message)) | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | // transitionUI owns the client-local handoff presentation. Every method is | |||
| 49 | - | // called by the active attach loop, preserving the terminal's single writer | |||
| 50 | - | // rule while network operations expose timer/resize events. | |||
| 51 | - | type transitionUI struct { | |||
| 52 | - | term ports.Terminal | |||
| 53 | - | clock ports.Clock | |||
| 54 | - | rawEntered *bool | |||
| 55 | - | timer ports.Timer | |||
| 56 | - | active bool | |||
| 57 | - | showing bool | |||
| 58 | - | frame int | |||
| 59 | - | message string | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | func newTransitionUI(term ports.Terminal, clock ports.Clock, rawEntered *bool) *transitionUI { | |||
| 63 | - | return &transitionUI{term: term, clock: clock, rawEntered: rawEntered} | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | func (u *transitionUI) start(target protocol.AttachTarget) { | |||
| 67 | - | if u == nil { | |||
| 68 | - | return | |||
| 69 | - | } | |||
| 70 | - | u.stop() | |||
| 71 | - | u.active = true | |||
| 72 | - | u.frame = 0 | |||
| 73 | - | u.message = transitionMessage(target) | |||
| 74 | - | if u.clock != nil { | |||
| 75 | - | u.timer = u.clock.NewTimer(transitionSpinnerDelay) | |||
| 76 | - | } | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | func (u *transitionUI) tickC() <-chan time.Time { | |||
| 80 | - | if u == nil || !u.active || u.timer == nil { | |||
| 81 | - | return nil | |||
| 82 | - | } | |||
| 83 | - | return u.timer.C() | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | func (u *transitionUI) advance() error { | |||
| 87 | - | if u == nil || !u.active { | |||
| 88 | - | return nil | |||
| 89 | - | } | |||
| 90 | - | geometry, err := u.term.Geometry() | |||
| 91 | - | if err != nil { | |||
| 92 | - | return fmt.Errorf("reading terminal geometry for transition toast: %w", err) | |||
| 93 | - | } | |||
| 94 | - | if err := u.redraw(geometry.Size); err != nil { | |||
| 95 | - | return err | |||
| 96 | - | } | |||
| 97 | - | u.frame = (u.frame + 1) % len(transitionSpinnerFrames) | |||
| 98 | - | if u.timer != nil { | |||
| 99 | - | u.timer.Reset(transitionSpinnerInterval) | |||
| 100 | - | } | |||
| 101 | - | return nil | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | func (u *transitionUI) redraw(size domain.Size) error { | |||
| 105 | - | if u == nil || !u.active || u.rawEntered == nil || !*u.rawEntered { | |||
| 106 | - | return nil | |||
| 107 | - | } | |||
| 108 | - | bounds, err := drawTransitionToast(u.term.Out(), size, u.frame, u.message) | |||
| 109 | - | if err != nil { | |||
| 110 | - | return fmt.Errorf("drawing transition toast: %w", err) | |||
| 111 | - | } | |||
| 112 | - | if bounds.Width == 0 || bounds.Height == 0 { | |||
| 113 | - | return nil | |||
| 114 | - | } | |||
| 115 | - | if err := u.term.Flush(); err != nil { | |||
| 116 | - | return fmt.Errorf("flushing transition toast: %w", err) | |||
| 117 | - | } | |||
| 118 | - | u.showing = true | |||
| 119 | - | return nil | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | func (u *transitionUI) stop() { | |||
| 123 | - | if u == nil { | |||
| 124 | - | return | |||
| 125 | - | } | |||
| 126 | - | if u.timer != nil { | |||
| 127 | - | u.timer.Stop() | |||
| 128 | - | u.timer = nil | |||
| 129 | - | } | |||
| 130 | - | u.active = false | |||
| 131 | - | u.showing = false | |||
| 132 | - | u.frame = 0 | |||
| 133 | - | u.message = "" | |||
| 134 | - | } | |||
M internal/usecase/client/transition_ui_test.go +7 -115
| @@ -1,126 +1,18 @@ | |||
| 1 | 1 | package client | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "io" | |
| 6 | - | "strings" | |
| 7 | 4 | "testing" | |
| 8 | - | "time" | |
| 9 | 5 | ||
| 10 | 6 | "github.com/stretchr/testify/require" | |
| 11 | 7 | ||
| 12 | 8 | "github.com/bnema/vev/internal/domain" | |
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | - | "github.com/bnema/vev/internal/protocol" | |
| 15 | 9 | ) | |
| 16 | 10 | ||
| 17 | - | func TestTransitionMessageDescribesSwitchAndStoppedRestore(t *testing.T) { | |
| 18 | - | live := protocol.AttachTarget{Endpoint: "remote", Session: "work"} | |
| 19 | - | stoppedTarget := domain.RemoteSessionTarget{ | |
| 20 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1}, | |
| 21 | - | SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"), | |
| 22 | - | } | |
| 23 | - | stopped := protocol.AttachTarget{Endpoint: "remote", Session: "work", SessionTarget: &protocol.SessionAttachTarget{LifecycleID: stoppedTarget.LifecycleID, SessionName: stoppedTarget.SessionName, TabIndex: protocol.NoTabIndex, Stopped: true}} | |
| 24 | - | ||
| 25 | - | require.Equal(t, "Switching to work@remote…", transitionMessage(live)) | |
| 26 | - | require.Equal(t, "Starting work@remote…", transitionMessage(stopped)) | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func TestDrawTransitionToastUsesAnimatedFrames(t *testing.T) { | |
| 30 | - | var first, second bytes.Buffer | |
| 31 | - | _, err := drawTransitionToast(&first, domain.Size{Cols: 80, Rows: 24}, 0, "Switching to work@remote…") | |
| 32 | - | require.NoError(t, err) | |
| 33 | - | _, err = drawTransitionToast(&second, domain.Size{Cols: 80, Rows: 24}, 1, "Switching to work@remote…") | |
| 34 | - | require.NoError(t, err) | |
| 35 | - | ||
| 36 | - | require.Contains(t, first.String(), string(transitionSpinnerFrames[0])) | |
| 37 | - | require.Contains(t, second.String(), string(transitionSpinnerFrames[1])) | |
| 38 | - | require.Contains(t, first.String(), "Switching to work@remote…") | |
| 39 | - | require.NotEqual(t, first.String(), second.String()) | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | type transitionTestTimer struct { | |
| 43 | - | ch chan time.Time | |
| 44 | - | resets []time.Duration | |
| 45 | - | stopped bool | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func (t *transitionTestTimer) C() <-chan time.Time { return t.ch } | |
| 49 | - | func (t *transitionTestTimer) Reset(delay time.Duration) bool { | |
| 50 | - | t.resets = append(t.resets, delay) | |
| 51 | - | return true | |
| 52 | - | } | |
| 53 | - | func (t *transitionTestTimer) Stop() bool { | |
| 54 | - | t.stopped = true | |
| 55 | - | return true | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | type transitionTestClock struct { | |
| 59 | - | timer *transitionTestTimer | |
| 60 | - | delays []time.Duration | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | func (c *transitionTestClock) Now() time.Time { return time.Time{} } | |
| 64 | - | func (c *transitionTestClock) NewTimer(delay time.Duration) ports.Timer { | |
| 65 | - | c.delays = append(c.delays, delay) | |
| 66 | - | return c.timer | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | type transitionTestTerminal struct { | |
| 70 | - | out bytes.Buffer | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | func (*transitionTestTerminal) EnterRaw() (func() error, error) { | |
| 74 | - | return func() error { return nil }, nil | |
| 75 | - | } | |
| 76 | - | func (*transitionTestTerminal) Geometry() (domain.Geometry, error) { | |
| 77 | - | return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil | |
| 78 | - | } | |
| 79 | - | func (*transitionTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil } | |
| 80 | - | func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") } | |
| 81 | - | func (t *transitionTestTerminal) Out() io.Writer { return &t.out } | |
| 82 | - | func (*transitionTestTerminal) Flush() error { return nil } | |
| 83 | - | ||
| 84 | - | func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) { | |
| 85 | - | for _, tt := range []struct { | |
| 86 | - | name string | |
| 87 | - | raw bool | |
| 88 | - | }{ | |
| 89 | - | {name: "raw", raw: true}, | |
| 90 | - | {name: "not raw", raw: false}, | |
| 91 | - | } { | |
| 92 | - | t.Run(tt.name, func(t *testing.T) { | |
| 93 | - | timer := &transitionTestTimer{ch: make(chan time.Time, 1)} | |
| 94 | - | clock := &transitionTestClock{timer: timer} | |
| 95 | - | term := &transitionTestTerminal{} | |
| 96 | - | raw := tt.raw | |
| 97 | - | ui := newTransitionUI(term, clock, &raw) | |
| 98 | - | ui.start(protocol.AttachTarget{Endpoint: "remote", Session: "work"}) | |
| 99 | - | ||
| 100 | - | require.Equal(t, []time.Duration{transitionSpinnerDelay}, clock.delays) | |
| 101 | - | require.NotNil(t, ui.tickC()) | |
| 102 | - | require.Empty(t, term.out.String(), "the delay prevents flashes on fast switches") | |
| 103 | - | require.NoError(t, ui.advance()) | |
| 104 | - | require.Equal(t, []time.Duration{transitionSpinnerInterval}, timer.resets) | |
| 105 | - | if !tt.raw { | |
| 106 | - | require.Empty(t, term.out.String()) | |
| 107 | - | require.False(t, ui.showing) | |
| 108 | - | ui.stop() | |
| 109 | - | require.True(t, timer.stopped) | |
| 110 | - | require.Nil(t, ui.tickC()) | |
| 111 | - | return | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | require.Contains(t, term.out.String(), "Switching to work@remote…") | |
| 115 | - | require.True(t, ui.showing) | |
| 116 | - | beforeResize := term.out.Len() | |
| 117 | - | require.NoError(t, ui.redraw(domain.Size{Cols: 100, Rows: 30})) | |
| 118 | - | require.Greater(t, term.out.Len(), beforeResize) | |
| 119 | - | require.True(t, strings.Contains(term.out.String(), "Switching to work@remote…")) | |
| 120 | - | ||
| 121 | - | ui.stop() | |
| 122 | - | require.True(t, timer.stopped) | |
| 123 | - | require.Nil(t, ui.tickC()) | |
| 124 | - | }) | |
| 125 | - | } | |
| 11 | + | func TestRenderTransitionNoticeAnimatedFrames(t *testing.T) { | |
| 12 | + | size := domain.Size{Cols: 80, Rows: 24} | |
| 13 | + | first := RenderTransitionNotice(size, 0, "Connecting to session…") | |
| 14 | + | second := RenderTransitionNotice(size, 1, "Connecting to session…") | |
| 15 | + | require.Contains(t, string(first), string(transitionSpinnerFrames[0])) | |
| 16 | + | require.Contains(t, string(second), string(transitionSpinnerFrames[1])) | |
| 17 | + | require.NotEqual(t, first, second) | |
| 126 | 18 | } | |
| @@ -1,126 +1,18 @@ | |||||
| 1 | package client | 1 | package client | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bytes" | |||
| 5 | - | "io" | |||
| 6 | - | "strings" | |||
| 7 | "testing" | 4 | "testing" | ||
| 8 | - | "time" | |||
| 9 | 5 | ||||
| 10 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 11 | 7 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 13 | - | "github.com/bnema/vev/internal/ports" | |||
| 14 | - | "github.com/bnema/vev/internal/protocol" | |||
| 15 | ) | 9 | ) | ||
| 16 | 10 | ||||
| 17 | - | func TestTransitionMessageDescribesSwitchAndStoppedRestore(t *testing.T) { | 11 | + | func TestRenderTransitionNoticeAnimatedFrames(t *testing.T) { |
| 18 | - | live := protocol.AttachTarget{Endpoint: "remote", Session: "work"} | 12 | + | size := domain.Size{Cols: 80, Rows: 24} |
| 19 | - | stoppedTarget := domain.RemoteSessionTarget{ | 13 | + | first := RenderTransitionNotice(size, 0, "Connecting to session…") |
| 20 | - | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1}, | 14 | + | second := RenderTransitionNotice(size, 1, "Connecting to session…") |
| 21 | - | SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"), | 15 | + | require.Contains(t, string(first), string(transitionSpinnerFrames[0])) |
| 22 | - | } | 16 | + | require.Contains(t, string(second), string(transitionSpinnerFrames[1])) |
| 23 | - | stopped := protocol.AttachTarget{Endpoint: "remote", Session: "work", SessionTarget: &protocol.SessionAttachTarget{LifecycleID: stoppedTarget.LifecycleID, SessionName: stoppedTarget.SessionName, TabIndex: protocol.NoTabIndex, Stopped: true}} | 17 | + | require.NotEqual(t, first, second) |
| 24 | - | ||||
| 25 | - | require.Equal(t, "Switching to work@remote…", transitionMessage(live)) | |||
| 26 | - | require.Equal(t, "Starting work@remote…", transitionMessage(stopped)) | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func TestDrawTransitionToastUsesAnimatedFrames(t *testing.T) { | |||
| 30 | - | var first, second bytes.Buffer | |||
| 31 | - | _, err := drawTransitionToast(&first, domain.Size{Cols: 80, Rows: 24}, 0, "Switching to work@remote…") | |||
| 32 | - | require.NoError(t, err) | |||
| 33 | - | _, err = drawTransitionToast(&second, domain.Size{Cols: 80, Rows: 24}, 1, "Switching to work@remote…") | |||
| 34 | - | require.NoError(t, err) | |||
| 35 | - | ||||
| 36 | - | require.Contains(t, first.String(), string(transitionSpinnerFrames[0])) | |||
| 37 | - | require.Contains(t, second.String(), string(transitionSpinnerFrames[1])) | |||
| 38 | - | require.Contains(t, first.String(), "Switching to work@remote…") | |||
| 39 | - | require.NotEqual(t, first.String(), second.String()) | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | type transitionTestTimer struct { | |||
| 43 | - | ch chan time.Time | |||
| 44 | - | resets []time.Duration | |||
| 45 | - | stopped bool | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func (t *transitionTestTimer) C() <-chan time.Time { return t.ch } | |||
| 49 | - | func (t *transitionTestTimer) Reset(delay time.Duration) bool { | |||
| 50 | - | t.resets = append(t.resets, delay) | |||
| 51 | - | return true | |||
| 52 | - | } | |||
| 53 | - | func (t *transitionTestTimer) Stop() bool { | |||
| 54 | - | t.stopped = true | |||
| 55 | - | return true | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | type transitionTestClock struct { | |||
| 59 | - | timer *transitionTestTimer | |||
| 60 | - | delays []time.Duration | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | func (c *transitionTestClock) Now() time.Time { return time.Time{} } | |||
| 64 | - | func (c *transitionTestClock) NewTimer(delay time.Duration) ports.Timer { | |||
| 65 | - | c.delays = append(c.delays, delay) | |||
| 66 | - | return c.timer | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | type transitionTestTerminal struct { | |||
| 70 | - | out bytes.Buffer | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | func (*transitionTestTerminal) EnterRaw() (func() error, error) { | |||
| 74 | - | return func() error { return nil }, nil | |||
| 75 | - | } | |||
| 76 | - | func (*transitionTestTerminal) Geometry() (domain.Geometry, error) { | |||
| 77 | - | return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil | |||
| 78 | - | } | |||
| 79 | - | func (*transitionTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil } | |||
| 80 | - | func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") } | |||
| 81 | - | func (t *transitionTestTerminal) Out() io.Writer { return &t.out } | |||
| 82 | - | func (*transitionTestTerminal) Flush() error { return nil } | |||
| 83 | - | ||||
| 84 | - | func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) { | |||
| 85 | - | for _, tt := range []struct { | |||
| 86 | - | name string | |||
| 87 | - | raw bool | |||
| 88 | - | }{ | |||
| 89 | - | {name: "raw", raw: true}, | |||
| 90 | - | {name: "not raw", raw: false}, | |||
| 91 | - | } { | |||
| 92 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 93 | - | timer := &transitionTestTimer{ch: make(chan time.Time, 1)} | |||
| 94 | - | clock := &transitionTestClock{timer: timer} | |||
| 95 | - | term := &transitionTestTerminal{} | |||
| 96 | - | raw := tt.raw | |||
| 97 | - | ui := newTransitionUI(term, clock, &raw) | |||
| 98 | - | ui.start(protocol.AttachTarget{Endpoint: "remote", Session: "work"}) | |||
| 99 | - | ||||
| 100 | - | require.Equal(t, []time.Duration{transitionSpinnerDelay}, clock.delays) | |||
| 101 | - | require.NotNil(t, ui.tickC()) | |||
| 102 | - | require.Empty(t, term.out.String(), "the delay prevents flashes on fast switches") | |||
| 103 | - | require.NoError(t, ui.advance()) | |||
| 104 | - | require.Equal(t, []time.Duration{transitionSpinnerInterval}, timer.resets) | |||
| 105 | - | if !tt.raw { | |||
| 106 | - | require.Empty(t, term.out.String()) | |||
| 107 | - | require.False(t, ui.showing) | |||
| 108 | - | ui.stop() | |||
| 109 | - | require.True(t, timer.stopped) | |||
| 110 | - | require.Nil(t, ui.tickC()) | |||
| 111 | - | return | |||
| 112 | - | } | |||
| 113 | - | ||||
| 114 | - | require.Contains(t, term.out.String(), "Switching to work@remote…") | |||
| 115 | - | require.True(t, ui.showing) | |||
| 116 | - | beforeResize := term.out.Len() | |||
| 117 | - | require.NoError(t, ui.redraw(domain.Size{Cols: 100, Rows: 30})) | |||
| 118 | - | require.Greater(t, term.out.Len(), beforeResize) | |||
| 119 | - | require.True(t, strings.Contains(term.out.String(), "Switching to work@remote…")) | |||
| 120 | - | ||||
| 121 | - | ui.stop() | |||
| 122 | - | require.True(t, timer.stopped) | |||
| 123 | - | require.Nil(t, ui.tickC()) | |||
| 124 | - | }) | |||
| 125 | - | } | |||
| 126 | } | 18 | } | ||
M internal/usecase/client/ui.go +0 -73
| @@ -18,79 +18,6 @@ const uiActionHistory = 64 | |||
| 18 | 18 | // UI owns attachment-local action and waiter bookkeeping. It never holds its | |
| 19 | 19 | // mutex across terminal access, transport I/O, or predicate evaluation. | |
| 20 | 20 | ||
| 21 | - | // pickerOutcomeQueue carries decoded picker operations from the stdin-pump | |
| 22 | - | // goroutine to the attach loop. The pump never touches the terminal, the | |
| 23 | - | // control channel, or the picker model: it only reports what the user | |
| 24 | - | // typed, and the attach loop applies it through its sole terminal writer | |
| 25 | - | // and flushes typed sends in controlCh order. | |
| 26 | - | // | |
| 27 | - | // The queue is bounded: physical input is now a producer, so an unbounded | |
| 28 | - | // queue would let a paste or a stuck attach loop accumulate operations | |
| 29 | - | // without limit. offer blocks until the loop drains or the attempt ends. | |
| 30 | - | type pickerOutcomeQueue struct { | |
| 31 | - | mu sync.Mutex | |
| 32 | - | outcomes []pickerConsumeOutcome | |
| 33 | - | signal chan struct{} | |
| 34 | - | slots chan struct{} | |
| 35 | - | done <-chan struct{} | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // pickerOutcomeCapacity bounds queued picker operations between the pump and | |
| 39 | - | // the attach loop. | |
| 40 | - | const pickerOutcomeCapacity = 256 | |
| 41 | - | ||
| 42 | - | func newPickerOutcomeQueue(done <-chan struct{}) *pickerOutcomeQueue { | |
| 43 | - | queue := &pickerOutcomeQueue{ | |
| 44 | - | signal: make(chan struct{}, 1), | |
| 45 | - | slots: make(chan struct{}, pickerOutcomeCapacity), | |
| 46 | - | done: done, | |
| 47 | - | } | |
| 48 | - | for i := 0; i < pickerOutcomeCapacity; i++ { | |
| 49 | - | queue.slots <- struct{}{} | |
| 50 | - | } | |
| 51 | - | return queue | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // offer queues one decoded operation, waiting for room. A cancelled attempt | |
| 55 | - | // drops it: nothing can apply the operation any more. | |
| 56 | - | func (q *pickerOutcomeQueue) offer(outcome pickerConsumeOutcome) { | |
| 57 | - | if q == nil { | |
| 58 | - | return | |
| 59 | - | } | |
| 60 | - | select { | |
| 61 | - | case <-q.slots: | |
| 62 | - | case <-q.done: | |
| 63 | - | return | |
| 64 | - | } | |
| 65 | - | q.mu.Lock() | |
| 66 | - | q.outcomes = append(q.outcomes, outcome) | |
| 67 | - | q.mu.Unlock() | |
| 68 | - | select { | |
| 69 | - | case q.signal <- struct{}{}: | |
| 70 | - | default: | |
| 71 | - | } | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | // take drains queued operations in order and returns their capacity. The | |
| 75 | - | // caller holds no locks; sends keep controlCh ordering because the attach | |
| 76 | - | // loop flushes them before processing new frames. | |
| 77 | - | func (q *pickerOutcomeQueue) take() []pickerConsumeOutcome { | |
| 78 | - | if q == nil { | |
| 79 | - | return nil | |
| 80 | - | } | |
| 81 | - | q.mu.Lock() | |
| 82 | - | outcomes := q.outcomes | |
| 83 | - | q.outcomes = nil | |
| 84 | - | q.mu.Unlock() | |
| 85 | - | for range outcomes { | |
| 86 | - | select { | |
| 87 | - | case q.slots <- struct{}{}: | |
| 88 | - | default: | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | return outcomes | |
| 92 | - | } | |
| 93 | - | ||
| 94 | 21 | type UI struct { | |
| 95 | 22 | state ports.UIState | |
| 96 | 23 | clock ports.Clock | |
| @@ -18,79 +18,6 @@ const uiActionHistory = 64 | |||||
| 18 | // UI owns attachment-local action and waiter bookkeeping. It never holds its | 18 | // UI owns attachment-local action and waiter bookkeeping. It never holds its | ||
| 19 | // mutex across terminal access, transport I/O, or predicate evaluation. | 19 | // mutex across terminal access, transport I/O, or predicate evaluation. | ||
| 20 | 20 | ||||
| 21 | - | // pickerOutcomeQueue carries decoded picker operations from the stdin-pump | |||
| 22 | - | // goroutine to the attach loop. The pump never touches the terminal, the | |||
| 23 | - | // control channel, or the picker model: it only reports what the user | |||
| 24 | - | // typed, and the attach loop applies it through its sole terminal writer | |||
| 25 | - | // and flushes typed sends in controlCh order. | |||
| 26 | - | // | |||
| 27 | - | // The queue is bounded: physical input is now a producer, so an unbounded | |||
| 28 | - | // queue would let a paste or a stuck attach loop accumulate operations | |||
| 29 | - | // without limit. offer blocks until the loop drains or the attempt ends. | |||
| 30 | - | type pickerOutcomeQueue struct { | |||
| 31 | - | mu sync.Mutex | |||
| 32 | - | outcomes []pickerConsumeOutcome | |||
| 33 | - | signal chan struct{} | |||
| 34 | - | slots chan struct{} | |||
| 35 | - | done <-chan struct{} | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | // pickerOutcomeCapacity bounds queued picker operations between the pump and | |||
| 39 | - | // the attach loop. | |||
| 40 | - | const pickerOutcomeCapacity = 256 | |||
| 41 | - | ||||
| 42 | - | func newPickerOutcomeQueue(done <-chan struct{}) *pickerOutcomeQueue { | |||
| 43 | - | queue := &pickerOutcomeQueue{ | |||
| 44 | - | signal: make(chan struct{}, 1), | |||
| 45 | - | slots: make(chan struct{}, pickerOutcomeCapacity), | |||
| 46 | - | done: done, | |||
| 47 | - | } | |||
| 48 | - | for i := 0; i < pickerOutcomeCapacity; i++ { | |||
| 49 | - | queue.slots <- struct{}{} | |||
| 50 | - | } | |||
| 51 | - | return queue | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | // offer queues one decoded operation, waiting for room. A cancelled attempt | |||
| 55 | - | // drops it: nothing can apply the operation any more. | |||
| 56 | - | func (q *pickerOutcomeQueue) offer(outcome pickerConsumeOutcome) { | |||
| 57 | - | if q == nil { | |||
| 58 | - | return | |||
| 59 | - | } | |||
| 60 | - | select { | |||
| 61 | - | case <-q.slots: | |||
| 62 | - | case <-q.done: | |||
| 63 | - | return | |||
| 64 | - | } | |||
| 65 | - | q.mu.Lock() | |||
| 66 | - | q.outcomes = append(q.outcomes, outcome) | |||
| 67 | - | q.mu.Unlock() | |||
| 68 | - | select { | |||
| 69 | - | case q.signal <- struct{}{}: | |||
| 70 | - | default: | |||
| 71 | - | } | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | // take drains queued operations in order and returns their capacity. The | |||
| 75 | - | // caller holds no locks; sends keep controlCh ordering because the attach | |||
| 76 | - | // loop flushes them before processing new frames. | |||
| 77 | - | func (q *pickerOutcomeQueue) take() []pickerConsumeOutcome { | |||
| 78 | - | if q == nil { | |||
| 79 | - | return nil | |||
| 80 | - | } | |||
| 81 | - | q.mu.Lock() | |||
| 82 | - | outcomes := q.outcomes | |||
| 83 | - | q.outcomes = nil | |||
| 84 | - | q.mu.Unlock() | |||
| 85 | - | for range outcomes { | |||
| 86 | - | select { | |||
| 87 | - | case q.slots <- struct{}{}: | |||
| 88 | - | default: | |||
| 89 | - | } | |||
| 90 | - | } | |||
| 91 | - | return outcomes | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | type UI struct { | 21 | type UI struct { | ||
| 95 | state ports.UIState | 22 | state ports.UIState | ||
| 96 | clock ports.Clock | 23 | clock ports.Clock | ||
M internal/usecase/client/ui_actions.go +3 -1
| @@ -274,7 +274,9 @@ func (u *UI) trackDispatch(ctx context.Context, id uint64, dispatched <-chan boo | |||
| 274 | 274 | u.mu.Lock() | |
| 275 | 275 | if _, exists := u.records[id]; exists { | |
| 276 | 276 | u.dispatched[id] = true | |
| 277 | - | u.completeHandoffLocked() | |
| 277 | + | if u.handoff != nil && u.handoff.boundary.Revision != 0 { | |
| 278 | + | u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary) | |
| 279 | + | } | |
| 278 | 280 | } | |
| 279 | 281 | u.mu.Unlock() | |
| 280 | 282 | } | |
| @@ -274,7 +274,9 @@ func (u *UI) trackDispatch(ctx context.Context, id uint64, dispatched <-chan boo | |||||
| 274 | u.mu.Lock() | 274 | u.mu.Lock() | ||
| 275 | if _, exists := u.records[id]; exists { | 275 | if _, exists := u.records[id]; exists { | ||
| 276 | u.dispatched[id] = true | 276 | u.dispatched[id] = true | ||
| 277 | - | u.completeHandoffLocked() | 277 | + | if u.handoff != nil && u.handoff.boundary.Revision != 0 { |
| 278 | + | u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary) | |||
| 279 | + | } | |||
| 278 | } | 280 | } | ||
| 279 | u.mu.Unlock() | 281 | u.mu.Unlock() | ||
| 280 | } | 282 | } | ||
D internal/usecase/client/ui_handoff.go +0 -82
| @@ -1,82 +0,0 @@ | |||
| 1 | - | package client | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/ports" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // follow joins an immutable navigation cause to the UI's existing handoff. | |
| 10 | - | // It selects no route and never retries or opens a connection itself. | |
| 11 | - | func (u *UI) follow(generation, actionID uint64) bool { | |
| 12 | - | var dispatchContext context.Context | |
| 13 | - | startDispatch := false | |
| 14 | - | u.mu.Lock() | |
| 15 | - | record, exists := u.records[actionID] | |
| 16 | - | if actionID == 0 || !exists || generation != u.generation || record.Context.Generation != generation || record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed { | |
| 17 | - | u.mu.Unlock() | |
| 18 | - | return false | |
| 19 | - | } | |
| 20 | - | if u.pending != 0 && u.pending != actionID { | |
| 21 | - | u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |
| 22 | - | } | |
| 23 | - | if record.Status == ports.UIActionProcessed { | |
| 24 | - | dispatchContext = u.foreground | |
| 25 | - | if dispatchContext == nil { | |
| 26 | - | dispatchContext = context.Background() | |
| 27 | - | } | |
| 28 | - | startDispatch = true | |
| 29 | - | } | |
| 30 | - | record.Status = ports.UIActionPending | |
| 31 | - | u.records[actionID] = record | |
| 32 | - | u.pending = actionID | |
| 33 | - | u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation} | |
| 34 | - | u.signalLocked() | |
| 35 | - | u.mu.Unlock() | |
| 36 | - | if startDispatch { | |
| 37 | - | go u.trackDispatch(dispatchContext, actionID, nil) | |
| 38 | - | } | |
| 39 | - | return true | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // completeLocal runs after the foreground's FIFO callback barrier, without | |
| 43 | - | // inventing a daemon fence for an action consumed entirely by the client. | |
| 44 | - | func (u *UI) completeLocal(generation, actionID uint64) { | |
| 45 | - | snapshot, err := u.state.Snapshot() | |
| 46 | - | if err != nil { | |
| 47 | - | return | |
| 48 | - | } | |
| 49 | - | u.mu.Lock() | |
| 50 | - | defer u.mu.Unlock() | |
| 51 | - | record, exists := u.records[actionID] | |
| 52 | - | if !exists || record.Status != ports.UIActionPending || generation != u.generation || snapshot.Context.Generation != generation || u.handoff != nil { | |
| 53 | - | return | |
| 54 | - | } | |
| 55 | - | u.finishLocked(actionID, ports.UIActionProcessed, ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func (u *UI) failHandoff() { | |
| 59 | - | u.mu.Lock() | |
| 60 | - | defer u.mu.Unlock() | |
| 61 | - | if u.handoff != nil { | |
| 62 | - | u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{}) | |
| 63 | - | } | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // destinationFull is called only after the attachment owner validates, writes, | |
| 67 | - | // and publishes its destination full Output. Identity alone is not proof. | |
| 68 | - | func (u *UI) destinationFull(generation uint64) { | |
| 69 | - | u.mu.Lock() | |
| 70 | - | defer u.mu.Unlock() | |
| 71 | - | if u.handoff == nil || u.handoff.destinationGeneration != generation || generation == u.handoff.sourceGeneration || u.boundary.Context.Generation != generation || u.boundary.Context.Status != ports.UIStatusAttached { | |
| 72 | - | return | |
| 73 | - | } | |
| 74 | - | u.handoff.boundary = u.boundary | |
| 75 | - | u.completeHandoffLocked() | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | func (u *UI) completeHandoffLocked() { | |
| 79 | - | if u.handoff != nil && u.handoff.boundary.Revision != 0 && u.dispatched[u.handoff.actionID] { | |
| 80 | - | u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary) | |
| 81 | - | } | |
| 82 | - | } | |
| @@ -1,82 +0,0 @@ | |||||
| 1 | - | package client | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/internal/ports" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | // follow joins an immutable navigation cause to the UI's existing handoff. | |||
| 10 | - | // It selects no route and never retries or opens a connection itself. | |||
| 11 | - | func (u *UI) follow(generation, actionID uint64) bool { | |||
| 12 | - | var dispatchContext context.Context | |||
| 13 | - | startDispatch := false | |||
| 14 | - | u.mu.Lock() | |||
| 15 | - | record, exists := u.records[actionID] | |||
| 16 | - | if actionID == 0 || !exists || generation != u.generation || record.Context.Generation != generation || record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed { | |||
| 17 | - | u.mu.Unlock() | |||
| 18 | - | return false | |||
| 19 | - | } | |||
| 20 | - | if u.pending != 0 && u.pending != actionID { | |||
| 21 | - | u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |||
| 22 | - | } | |||
| 23 | - | if record.Status == ports.UIActionProcessed { | |||
| 24 | - | dispatchContext = u.foreground | |||
| 25 | - | if dispatchContext == nil { | |||
| 26 | - | dispatchContext = context.Background() | |||
| 27 | - | } | |||
| 28 | - | startDispatch = true | |||
| 29 | - | } | |||
| 30 | - | record.Status = ports.UIActionPending | |||
| 31 | - | u.records[actionID] = record | |||
| 32 | - | u.pending = actionID | |||
| 33 | - | u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation} | |||
| 34 | - | u.signalLocked() | |||
| 35 | - | u.mu.Unlock() | |||
| 36 | - | if startDispatch { | |||
| 37 | - | go u.trackDispatch(dispatchContext, actionID, nil) | |||
| 38 | - | } | |||
| 39 | - | return true | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | // completeLocal runs after the foreground's FIFO callback barrier, without | |||
| 43 | - | // inventing a daemon fence for an action consumed entirely by the client. | |||
| 44 | - | func (u *UI) completeLocal(generation, actionID uint64) { | |||
| 45 | - | snapshot, err := u.state.Snapshot() | |||
| 46 | - | if err != nil { | |||
| 47 | - | return | |||
| 48 | - | } | |||
| 49 | - | u.mu.Lock() | |||
| 50 | - | defer u.mu.Unlock() | |||
| 51 | - | record, exists := u.records[actionID] | |||
| 52 | - | if !exists || record.Status != ports.UIActionPending || generation != u.generation || snapshot.Context.Generation != generation || u.handoff != nil { | |||
| 53 | - | return | |||
| 54 | - | } | |||
| 55 | - | u.finishLocked(actionID, ports.UIActionProcessed, ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}) | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | func (u *UI) failHandoff() { | |||
| 59 | - | u.mu.Lock() | |||
| 60 | - | defer u.mu.Unlock() | |||
| 61 | - | if u.handoff != nil { | |||
| 62 | - | u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{}) | |||
| 63 | - | } | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | // destinationFull is called only after the attachment owner validates, writes, | |||
| 67 | - | // and publishes its destination full Output. Identity alone is not proof. | |||
| 68 | - | func (u *UI) destinationFull(generation uint64) { | |||
| 69 | - | u.mu.Lock() | |||
| 70 | - | defer u.mu.Unlock() | |||
| 71 | - | if u.handoff == nil || u.handoff.destinationGeneration != generation || generation == u.handoff.sourceGeneration || u.boundary.Context.Generation != generation || u.boundary.Context.Status != ports.UIStatusAttached { | |||
| 72 | - | return | |||
| 73 | - | } | |||
| 74 | - | u.handoff.boundary = u.boundary | |||
| 75 | - | u.completeHandoffLocked() | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | func (u *UI) completeHandoffLocked() { | |||
| 79 | - | if u.handoff != nil && u.handoff.boundary.Revision != 0 && u.dispatched[u.handoff.actionID] { | |||
| 80 | - | u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary) | |||
| 81 | - | } | |||
| 82 | - | } | |||