feat(remote): enforce proxy command and lifecycle ownership
- commit
- 0f7a788d5be0f68a4ff5c8e440aaad235eb3f4ec
- author
- brice · · 2026-07-30 20:40 UTC
- committer
- brice · · 2026-07-31 18:59 UTC
- parent
- 7d5c4cb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
30 files · +3400 -173
M internal/usecase/daemon/attachment_transition.go +5 -0
| @@ -64,6 +64,7 @@ func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attach | |||||
| 64 | type attachmentTransitionResult struct { | 64 | type attachmentTransitionResult struct { | ||
| 65 | published attachmentRoleToken | 65 | published attachmentRoleToken | ||
| 66 | displaced attachmentRoleToken | 66 | displaced attachmentRoleToken | ||
| 67 | + | terminalDisplaced detachedAttachmentSnapshot | |||
| 67 | displacedInterrupted bool | 68 | displacedInterrupted bool | ||
| 68 | cleanups []renderLifecycleCleanup | 69 | cleanups []renderLifecycleCleanup | ||
| 69 | } | 70 | } | ||
| @@ -233,5 +234,9 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||||
| 233 | if result.published.role == attachmentActive { | 234 | if result.published.role == attachmentActive { | ||
| 234 | result.published.ac.clearSnatchedInput() | 235 | result.published.ac.clearSnatchedInput() | ||
| 235 | } | 236 | } | ||
| 237 | + | // Warm-proxy timers are external clock operations. Apply them only after | |||
| 238 | + | // frozen ownership publication and every architecture lock have completed; | |||
| 239 | + | // the lifecycle helper revalidates exact registry pointers and clients. | |||
| 240 | + | d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveRole) | |||
| 236 | return result, nil | 241 | return result, nil | ||
| 237 | } | 242 | } | ||
M internal/usecase/daemon/attachment_transition_publish.go +25 -8
| @@ -85,6 +85,7 @@ type attachmentPublication struct { | |||||
| 85 | targetCoordinator *renderCoordinator | 85 | targetCoordinator *renderCoordinator | ||
| 86 | releaseCoordinators func() | 86 | releaseCoordinators func() | ||
| 87 | displacedTransport transportSnapshot | 87 | displacedTransport transportSnapshot | ||
| 88 | + | terminalDisplaced bool | |||
| 88 | nextGeneration uint64 | 89 | nextGeneration uint64 | ||
| 89 | displacedGeneration uint64 | 90 | displacedGeneration uint64 | ||
| 90 | } | 91 | } | ||
| @@ -176,6 +177,10 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||||
| 176 | req := publication.req | 177 | req := publication.req | ||
| 177 | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | 178 | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | ||
| 178 | publication.displacedTransport = publication.old.transportSnapshot() | 179 | publication.displacedTransport = publication.old.transportSnapshot() | ||
| 180 | + | // A proxied daemon attachment has no local snatched UI and must never | |||
| 181 | + | // linger in that role. Replacing it is terminal so its owning local proxy | |||
| 182 | + | // receives ReasonReplaced and cannot kick the direct user back later. | |||
| 183 | + | publication.terminalDisplaced = publication.old.proxied | |||
| 179 | } | 184 | } | ||
| 180 | if publication.source != req.target { | 185 | if publication.source != req.target { | ||
| 181 | if publication.source.core().client == req.next { | 186 | if publication.source.core().client == req.next { | ||
| @@ -186,7 +191,11 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||||
| 186 | if req.targetRole == attachmentActive { | 191 | if req.targetRole == attachmentActive { | ||
| 187 | delete(req.target.core().snatched, req.next) | 192 | delete(req.target.core().snatched, req.next) | ||
| 188 | if publication.old != nil && publication.old != req.next { | 193 | if publication.old != nil && publication.old != req.next { | ||
| 189 | - | addSnatchedLocked(req.target, publication.old) | 194 | + | if publication.terminalDisplaced { |
| 195 | + | delete(req.target.core().snatched, publication.old) | |||
| 196 | + | } else { | |||
| 197 | + | addSnatchedLocked(req.target, publication.old) | |||
| 198 | + | } | |||
| 190 | } | 199 | } | ||
| 191 | req.target.core().client = req.next | 200 | req.target.core().client = req.next | ||
| 192 | } else { | 201 | } else { | ||
| @@ -195,6 +204,10 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||||
| 195 | publication.nextGeneration = req.next.roleGeneration.Add(1) | 204 | publication.nextGeneration = req.next.roleGeneration.Add(1) | ||
| 196 | if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive { | 205 | if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive { | ||
| 197 | publication.displacedGeneration = publication.old.roleGeneration.Add(1) | 206 | publication.displacedGeneration = publication.old.roleGeneration.Add(1) | ||
| 207 | + | if publication.terminalDisplaced { | |||
| 208 | + | publication.old.setSession(nil) | |||
| 209 | + | publication.old.invalidateFrozenRoleCapability() | |||
| 210 | + | } | |||
| 198 | } | 211 | } | ||
| 199 | req.next.setSession(req.target) | 212 | req.next.setSession(req.target) | ||
| 200 | } | 213 | } | ||
| @@ -223,14 +236,18 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att | |||||
| 223 | rebase: publication.source != req.target && req.expectedRole == attachmentActive, | 236 | rebase: publication.source != req.target && req.expectedRole == attachmentActive, | ||
| 224 | } | 237 | } | ||
| 225 | if publication.displacedGeneration != 0 { | 238 | if publication.displacedGeneration != 0 { | ||
| 226 | - | result.displaced = attachmentRoleToken{ | 239 | + | if publication.terminalDisplaced { |
| 227 | - | sess: req.target, | 240 | + | result.terminalDisplaced = detachedAttachmentSnapshot{ac: publication.old, transport: publication.displacedTransport} |
| 228 | - | ac: publication.old, | 241 | + | } else { |
| 229 | - | role: attachmentSnatched, | 242 | + | result.displaced = attachmentRoleToken{ |
| 230 | - | generation: publication.displacedGeneration, | 243 | + | sess: req.target, |
| 231 | - | transport: publication.displacedTransport, | 244 | + | ac: publication.old, |
| 245 | + | role: attachmentSnatched, | |||
| 246 | + | generation: publication.displacedGeneration, | |||
| 247 | + | transport: publication.displacedTransport, | |||
| 248 | + | } | |||
| 249 | + | result.displacedInterrupted = req.expectedTargetTransportInterrupted | |||
| 232 | } | 250 | } | ||
| 233 | - | result.displacedInterrupted = req.expectedTargetTransportInterrupted | |||
| 234 | } | 251 | } | ||
| 235 | // Membership, currentSession, lease, generation, and both exact admission | 252 | // Membership, currentSession, lease, generation, and both exact admission | ||
| 236 | // capabilities become visible as one frozen publication. The gates remain | 253 | // capabilities become visible as one frozen publication. The gates remain | ||
M internal/usecase/daemon/client_frame_routing.go +87 -14
| @@ -1,6 +1,11 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/internal/ports" | 3 | + | import ( |
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 8 | + | ) | |||
| 4 | 9 | ||||
| 5 | // runConnLoop is the per-connection input router: it pumps client messages | 10 | // runConnLoop is the per-connection input router: it pumps client messages | ||
| 6 | // until detach, EOF, or a transport error. Role is resolved after every Recv so | 11 | // until detach, EOF, or a transport error. Role is resolved after every Recv so | ||
| @@ -116,19 +121,7 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||||
| 116 | if derr != nil { | 121 | if derr != nil { | ||
| 117 | return false | 122 | return false | ||
| 118 | } | 123 | } | ||
| 119 | - | // Attached commands are intentionally admitted only on the proxied | 124 | + | result := d.executeAttachedCommand(token, request) |
| 120 | - | // attachment path. Phase 6 supplies execution; until then every | |||
| 121 | - | // decoded request is rejected with its original correlation ID. | |||
| 122 | - | valid := request.Version == ports.ProtocolVersion && request.Attached && request.RequestID != 0 && | |||
| 123 | - | request.TargetSession == "" && request.TargetTab == "" && request.TargetPane == "" | |||
| 124 | - | if !valid { | |||
| 125 | - | d.log.Warn("invalid attached command", "request_id", request.RequestID) | |||
| 126 | - | } | |||
| 127 | - | result := ports.CommandResult{ | |||
| 128 | - | RequestID: request.RequestID, | |||
| 129 | - | Code: ports.ErrNotScriptable, | |||
| 130 | - | Text: "attached command relay is not enabled", | |||
| 131 | - | } | |||
| 132 | if err := token.sendActiveControl(frameCommandResult(result)); err != nil { | 125 | if err := token.sendActiveControl(frameCommandResult(result)); err != nil { | ||
| 133 | token.endRoleEffect() | 126 | token.endRoleEffect() | ||
| 134 | d.detachOnRoleSendError(token, token.transport.transport) | 127 | d.detachOnRoleSendError(token, token.transport.transport) | ||
| @@ -148,6 +141,86 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||||
| 148 | return false | 141 | return false | ||
| 149 | } | 142 | } | ||
| 150 | 143 | ||||
| 144 | + | func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports.CommandRequest) ports.CommandResult { | |||
| 145 | + | result := ports.CommandResult{RequestID: request.RequestID} | |||
| 146 | + | if request.Version != ports.ProtocolVersion { | |||
| 147 | + | result.Code = ports.ErrInvalidCommandArgs | |||
| 148 | + | result.Text = "unsupported command protocol version" | |||
| 149 | + | return result | |||
| 150 | + | } | |||
| 151 | + | if !request.Attached { | |||
| 152 | + | result.Code = ports.ErrInvalidCommandArgs | |||
| 153 | + | result.Text = "attached command flag is required" | |||
| 154 | + | return result | |||
| 155 | + | } | |||
| 156 | + | if request.RequestID == 0 { | |||
| 157 | + | result.Code = ports.ErrInvalidCommandArgs | |||
| 158 | + | result.Text = "command request id is required" | |||
| 159 | + | return result | |||
| 160 | + | } | |||
| 161 | + | if request.Self { | |||
| 162 | + | result.Code = ports.ErrInvalidCommandArgs | |||
| 163 | + | result.Text = "attached commands cannot target themselves" | |||
| 164 | + | return result | |||
| 165 | + | } | |||
| 166 | + | if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" { | |||
| 167 | + | result.Code = ports.ErrInvalidCommandArgs | |||
| 168 | + | result.Text = "attached commands cannot override their active session target" | |||
| 169 | + | return result | |||
| 170 | + | } | |||
| 171 | + | sess, ok := localSession(token.sess) | |||
| 172 | + | if !ok || !token.activeEffect() { | |||
| 173 | + | result.Code = ports.ErrNoSuchTarget | |||
| 174 | + | result.Text = "attached session is no longer active" | |||
| 175 | + | return result | |||
| 176 | + | } | |||
| 177 | + | cmd, ok := command.BySlug(request.Slug) | |||
| 178 | + | if !ok { | |||
| 179 | + | result.Code = ports.ErrUnknownCommand | |||
| 180 | + | result.Text = "unknown command: " + request.Slug | |||
| 181 | + | return result | |||
| 182 | + | } | |||
| 183 | + | if !cmd.PaletteVisible { | |||
| 184 | + | result.Code = ports.ErrNotScriptable | |||
| 185 | + | result.Text = request.Slug + " is not available in the palette" | |||
| 186 | + | return result | |||
| 187 | + | } | |||
| 188 | + | if cmd.Run == nil { | |||
| 189 | + | result.Code = ports.ErrNotScriptable | |||
| 190 | + | result.Text = request.Slug + " is not scriptable" | |||
| 191 | + | return result | |||
| 192 | + | } | |||
| 193 | + | if !proxyAttachedCommandOwnedRemotely(cmd.Slug) { | |||
| 194 | + | result.Code = ports.ErrNotScriptable | |||
| 195 | + | result.Text = request.Slug + " is owned by the local proxy daemon" | |||
| 196 | + | return result | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // The frame's role token is the target capability. No registry/name lookup is | |||
| 200 | + | // performed, so the command cannot escape to another remote session. | |||
| 201 | + | sess.dispatchMu.Lock() | |||
| 202 | + | err := cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args) | |||
| 203 | + | sess.dispatchMu.Unlock() | |||
| 204 | + | if err == nil { | |||
| 205 | + | result.OK = true | |||
| 206 | + | return result | |||
| 207 | + | } | |||
| 208 | + | if errors.Is(err, command.ErrInvalidArguments) { | |||
| 209 | + | result.Code = ports.ErrInvalidCommandArgs | |||
| 210 | + | result.Text = "usage: " + cmd.Usage | |||
| 211 | + | return result | |||
| 212 | + | } | |||
| 213 | + | result.Code = ports.ErrInternal | |||
| 214 | + | result.Text = err.Error() | |||
| 215 | + | return result | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // proxyAttachedCommandOwnedRemotely applies the palette's canonical ownership | |||
| 219 | + | // policy to command frames received by the remote daemon. | |||
| 220 | + | func proxyAttachedCommandOwnedRemotely(slug string) bool { | |||
| 221 | + | return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote | |||
| 222 | + | } | |||
| 223 | + | ||||
| 151 | // resetActiveOutput rebases only the exact proxied role and transport admitted | 224 | // resetActiveOutput rebases only the exact proxied role and transport admitted | ||
| 152 | // by the frame router. sendMu serializes the rebase with every output send; | 225 | // by the frame router. sendMu serializes the rebase with every output send; | ||
| 153 | // transport revalidation under that lock rejects a link replaced while waiting. | 226 | // transport revalidation under that lock rejects a link replaced while waiting. | ||
M internal/usecase/daemon/client_transport_errors.go +43 -5
| @@ -29,12 +29,23 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool { | 31 | func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool { | ||
| 32 | - | sess, ok := localSession(token.sess) | 32 | + | if token.effect == nil { |
| 33 | - | if !ok || token.effect == nil { | |||
| 34 | return false | 33 | return false | ||
| 35 | } | 34 | } | ||
| 36 | token.effect.bindActionEnd(d, "detach") | 35 | token.effect.bindActionEnd(d, "detach") | ||
| 37 | token.effect.End() | 36 | token.effect.End() | ||
| 37 | + | if proxy, ok := token.sess.(*proxySession); ok { | |||
| 38 | + | if !d.detachIfRoleCurrent(token) { | |||
| 39 | + | return false | |||
| 40 | + | } | |||
| 41 | + | d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit) | |||
| 42 | + | d.armProxyWarm(proxy) | |||
| 43 | + | return true | |||
| 44 | + | } | |||
| 45 | + | sess, ok := localSession(token.sess) | |||
| 46 | + | if !ok { | |||
| 47 | + | return false | |||
| 48 | + | } | |||
| 38 | var parkingToken uint64 | 49 | var parkingToken uint64 | ||
| 39 | if !explicit { | 50 | if !explicit { | ||
| 40 | parkingToken = d.markParkingInFlight(sess, token.ac) | 51 | parkingToken = d.markParkingInFlight(sess, token.ac) | ||
| @@ -116,22 +127,37 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai | |||||
| 116 | return | 127 | return | ||
| 117 | } | 128 | } | ||
| 118 | expected := transportSnapshot{} | 129 | expected := transportSnapshot{} | ||
| 130 | + | oldTr := failed | |||
| 119 | if failed != nil { | 131 | if failed != nil { | ||
| 120 | expected = ac.transportSnapshot() | 132 | expected = ac.transportSnapshot() | ||
| 121 | if expected.transport != failed { | 133 | if expected.transport != failed { | ||
| 122 | return | 134 | return | ||
| 123 | } | 135 | } | ||
| 136 | + | } else { | |||
| 137 | + | oldTr = ac.transport() | |||
| 124 | } | 138 | } | ||
| 125 | if !d.detachProxyIfCurrentTransport(p, ac, expected) { | 139 | if !d.detachProxyIfCurrentTransport(p, ac, expected) { | ||
| 126 | return | 140 | return | ||
| 127 | } | 141 | } | ||
| 142 | + | d.finishProxyClientGone(p, ac, oldTr, false) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // finishProxyClientGone retires external client ownership after the exact proxy | |||
| 146 | + | // role has been unpublished. Warm-timer publication remains with the caller so | |||
| 147 | + | // each detach path can arm once, after p.client is nil. | |||
| 148 | + | func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) { | |||
| 128 | if rc := p.coordinator.Load(); rc != nil { | 149 | if rc := p.coordinator.Load(); rc != nil { | ||
| 129 | rc.noteDetach(ac) | 150 | rc.noteDetach(ac) | ||
| 130 | } | 151 | } | ||
| 131 | - | d.unregisterPreview(ac) | 152 | + | if ac.overlays != nil { |
| 153 | + | d.closePicker(ac) | |||
| 154 | + | } | |||
| 132 | ac.clearPreviousSession() | 155 | ac.clearPreviousSession() | ||
| 156 | + | if explicit { | |||
| 157 | + | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | |||
| 158 | + | } | |||
| 133 | _ = ac.closeCapturedTransport(failed) | 159 | _ = ac.closeCapturedTransport(failed) | ||
| 134 | - | d.log.Warn("detached proxy client after send error", "host", p.key.Host, "session", p.key.Name) | 160 | + | d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit) |
| 135 | } | 161 | } | ||
| 136 | 162 | ||||
| 137 | func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) { | 163 | func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) { | ||
| @@ -139,8 +165,20 @@ func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.T | |||||
| 139 | } | 165 | } | ||
| 140 | 166 | ||||
| 141 | func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) { | 167 | func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) { | ||
| 168 | + | // A delayed sender may report after the client has rebound. Only the exact | |||
| 169 | + | // transport captured by this role is allowed to detach either lifecycle. | |||
| 170 | + | if failed == nil || failed != token.transport.transport { | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | if proxy, ok := token.sess.(*proxySession); ok { | |||
| 174 | + | if d.detachIfRoleCurrentUntil(token, done) { | |||
| 175 | + | d.finishProxyClientGone(proxy, token.ac, failed, false) | |||
| 176 | + | d.armProxyWarm(proxy) | |||
| 177 | + | } | |||
| 178 | + | return | |||
| 179 | + | } | |||
| 142 | sess, ok := localSession(token.sess) | 180 | sess, ok := localSession(token.sess) | ||
| 143 | - | if !ok || failed != token.transport.transport { | 181 | + | if !ok { |
| 144 | return | 182 | return | ||
| 145 | } | 183 | } | ||
| 146 | parkingToken := d.markParkingInFlight(sess, token.ac) | 184 | parkingToken := d.markParkingInFlight(sess, token.ac) | ||
M internal/usecase/daemon/config_test.go +3 -1
| @@ -21,7 +21,9 @@ type captureKeyHandler struct { | |||||
| 21 | func (h *captureKeyHandler) Forward(data []byte) { | 21 | func (h *captureKeyHandler) Forward(data []byte) { | ||
| 22 | h.forward = append(h.forward, append([]byte(nil), data...)) | 22 | h.forward = append(h.forward, append([]byte(nil), data...)) | ||
| 23 | } | 23 | } | ||
| 24 | - | func (h *captureKeyHandler) Action(action keys.Action) { h.actions = append(h.actions, action) } | 24 | + | func (h *captureKeyHandler) Action(action keys.Action, _ []byte) { |
| 25 | + | h.actions = append(h.actions, action) | |||
| 26 | + | } | |||
| 25 | 27 | ||||
| 26 | func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | 28 | func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | ||
| 27 | d := newTestDaemon(t, nil, stubClock{}) | 29 | d := newTestDaemon(t, nil, stubClock{}) | ||
M internal/usecase/daemon/input.go +41 -13
| @@ -43,6 +43,23 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||||
| 43 | func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) { | 43 | func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) { | ||
| 44 | ac := token.ac | 44 | ac := token.ac | ||
| 45 | ac.initOverlays() | 45 | ac.initOverlays() | ||
| 46 | + | if proxy, ok := token.sess.(*proxySession); ok { | |||
| 47 | + | handler := proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token} | |||
| 48 | + | ac.mouseScan.Scan(data, | |||
| 49 | + | func(ev mouse.Event) { | |||
| 50 | + | if token.activeEffect() && !proxyMouseOwnedLocally(ac.overlays) { | |||
| 51 | + | handler.Mouse(ev) | |||
| 52 | + | } | |||
| 53 | + | }, | |||
| 54 | + | func(b []byte) { | |||
| 55 | + | if !token.activeEffect() || ac.overlays.HandleInput(d, b, token.effect) || !token.activeEffect() { | |||
| 56 | + | return | |||
| 57 | + | } | |||
| 58 | + | ac.keys.RouteWithHandler(b, handler) | |||
| 59 | + | }, | |||
| 60 | + | ) | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 46 | ac.mouseScan.Scan(data, | 63 | ac.mouseScan.Scan(data, | ||
| 47 | func(ev mouse.Event) { | 64 | func(ev mouse.Event) { | ||
| 48 | if token.activeEffect() { | 65 | if token.activeEffect() { | ||
| @@ -357,7 +374,7 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||||
| 357 | h.d.writeToPane(sess, p, data) | 374 | h.d.writeToPane(sess, p, data) | ||
| 358 | } | 375 | } | ||
| 359 | 376 | ||||
| 360 | - | func (h daemonKeyHandler) Action(action keys.Action) { | 377 | + | func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { |
| 361 | sess, effect, owned := h.acquireRoleEffect() | 378 | sess, effect, owned := h.acquireRoleEffect() | ||
| 362 | if sess == nil { | 379 | if sess == nil { | ||
| 363 | return | 380 | return | ||
| @@ -390,6 +407,13 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 390 | case keys.ActionOpenPalette: | 407 | case keys.ActionOpenPalette: | ||
| 391 | h.d.enterPalette(sess, h.ac) | 408 | h.d.enterPalette(sess, h.ac) | ||
| 392 | case keys.ActionJumpAttention: | 409 | case keys.ActionJumpAttention: | ||
| 410 | + | if !proxiedJumpSearchesOtherSessions(h.ac.proxied) { | |||
| 411 | + | if idx, ok := oldestAttentionTab(sess); ok && sess.switchTab(idx) { | |||
| 412 | + | h.d.activateTab(sess, sess.activeTab()) | |||
| 413 | + | h.d.invalidateRender(sess, h.ac, true, "input.go") | |||
| 414 | + | } | |||
| 415 | + | return | |||
| 416 | + | } | |||
| 393 | if effect == nil { | 417 | if effect == nil { | ||
| 394 | if err := h.d.jumpAttention(sess, h.ac); err != nil { | 418 | if err := h.d.jumpAttention(sess, h.ac); err != nil { | ||
| 395 | h.d.reportError(sess, err) | 419 | h.d.reportError(sess, err) | ||
| @@ -408,21 +432,13 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 408 | h.d.reportError(sess, err) | 432 | h.d.reportError(sess, err) | ||
| 409 | } | 433 | } | ||
| 410 | case keys.ActionFocusPaneLeft: | 434 | case keys.ActionFocusPaneLeft: | ||
| 411 | - | if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && !errors.Is(err, errAttachmentTransition) { | 435 | + | h.focus(sess, layout.Left, effect) |
| 412 | - | h.d.reportError(sess, err) | |||
| 413 | - | } | |||
| 414 | case keys.ActionFocusPaneRight: | 436 | case keys.ActionFocusPaneRight: | ||
| 415 | - | if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && !errors.Is(err, errAttachmentTransition) { | 437 | + | h.focus(sess, layout.Right, effect) |
| 416 | - | h.d.reportError(sess, err) | |||
| 417 | - | } | |||
| 418 | case keys.ActionFocusPaneUp: | 438 | case keys.ActionFocusPaneUp: | ||
| 419 | - | if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && !errors.Is(err, errAttachmentTransition) { | 439 | + | h.focus(sess, layout.Up, effect) |
| 420 | - | h.d.reportError(sess, err) | |||
| 421 | - | } | |||
| 422 | case keys.ActionFocusPaneDown: | 440 | case keys.ActionFocusPaneDown: | ||
| 423 | - | if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && !errors.Is(err, errAttachmentTransition) { | 441 | + | h.focus(sess, layout.Down, effect) |
| 424 | - | h.d.reportError(sess, err) | |||
| 425 | - | } | |||
| 426 | case keys.ActionGrowPaneWidth: | 442 | case keys.ActionGrowPaneWidth: | ||
| 427 | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) | 443 | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) | ||
| 428 | case keys.ActionShrinkPaneWidth: | 444 | case keys.ActionShrinkPaneWidth: | ||
| @@ -447,3 +463,15 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 447 | } | 463 | } | ||
| 448 | } | 464 | } | ||
| 449 | } | 465 | } | ||
| 466 | + | ||||
| 467 | + | func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *roleEffectTicket) { | |||
| 468 | + | var err error | |||
| 469 | + | if h.ac.proxied { | |||
| 470 | + | err = h.d.focusDirProxied(sess, h.ac, dir) | |||
| 471 | + | } else { | |||
| 472 | + | err = h.d.focusDir(sess, h.ac, dir, effect) | |||
| 473 | + | } | |||
| 474 | + | if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) { | |||
| 475 | + | h.d.reportError(sess, err) | |||
| 476 | + | } | |||
| 477 | + | } | |||
M internal/usecase/daemon/input_test.go +9 -9
| @@ -118,7 +118,7 @@ func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) { | |||||
| 118 | layout.ErrTooSmall, | 118 | layout.ErrTooSmall, | ||
| 119 | )} | 119 | )} | ||
| 120 | 120 | ||||
| 121 | - | daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action) | 121 | + | daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action, nil) |
| 122 | 122 | ||||
| 123 | history := d.notices.history() | 123 | history := d.notices.history() | ||
| 124 | require.Len(t, history, 1) | 124 | require.Len(t, history, 1) | ||
| @@ -154,7 +154,7 @@ func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) { | |||||
| 154 | paletteSpy := &actionRunnerSpy{} | 154 | paletteSpy := &actionRunnerSpy{} | ||
| 155 | controlSpy := &actionRunnerSpy{} | 155 | controlSpy := &actionRunnerSpy{} | ||
| 156 | 156 | ||||
| 157 | - | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action) | 157 | + | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action, nil) |
| 158 | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy})) | 158 | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy})) | ||
| 159 | require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy})) | 159 | require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy})) | ||
| 160 | 160 | ||||
| @@ -238,7 +238,7 @@ func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) { | |||||
| 238 | 238 | ||||
| 239 | switch adapter { | 239 | switch adapter { | ||
| 240 | case "key": | 240 | case "key": | ||
| 241 | - | daemonKeyHandler{d: d, ac: ac}.Action(tt.action) | 241 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil) |
| 242 | case "palette": | 242 | case "palette": | ||
| 243 | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac})) | 243 | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac})) | ||
| 244 | case "control": | 244 | case "control": | ||
| @@ -400,7 +400,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||||
| 400 | target.mu.Unlock() | 400 | target.mu.Unlock() | ||
| 401 | // Use the real key action: it requests the mandatory complete repaint for | 401 | // Use the real key action: it requests the mandatory complete repaint for | ||
| 402 | // the first target-tab frame. | 402 | // the first target-tab frame. | ||
| 403 | - | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2) | 403 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil) |
| 404 | require.Equal(t, 1, activeTabIndex(sess)) | 404 | require.Equal(t, 1, activeTabIndex(sess)) | ||
| 405 | 405 | ||||
| 406 | second := awaitFrame(t, sends, ports.MsgOutput) | 406 | second := awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -635,7 +635,7 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) { | |||||
| 635 | tb.mu.Unlock() | 635 | tb.mu.Unlock() | ||
| 636 | installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true) | 636 | installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true) | ||
| 637 | 637 | ||||
| 638 | - | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight) | 638 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight, nil) |
| 639 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | 639 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | ||
| 640 | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x")) | 640 | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x")) | ||
| 641 | requirePTYWrite(t, floatingWrites, []byte("x")) | 641 | requirePTYWrite(t, floatingWrites, []byte("x")) | ||
| @@ -647,7 +647,7 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) { | |||||
| 647 | func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) { | 647 | func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) { | ||
| 648 | d, _, ac, _ := newManualSessionWithPTYs(t, nil) | 648 | d, _, ac, _ := newManualSessionWithPTYs(t, nil) | ||
| 649 | 649 | ||||
| 650 | - | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft) | 650 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft, nil) |
| 651 | 651 | ||||
| 652 | require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent") | 652 | require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent") | ||
| 653 | } | 653 | } | ||
| @@ -663,7 +663,7 @@ func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) { | |||||
| 663 | tb.tree = nil | 663 | tb.tree = nil | ||
| 664 | tb.mu.Unlock() | 664 | tb.mu.Unlock() | ||
| 665 | 665 | ||||
| 666 | - | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft) | 666 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft, nil) |
| 667 | 667 | ||||
| 668 | history := d.notices.history() | 668 | history := d.notices.history() | ||
| 669 | require.Len(t, history, 2, "the layout error and its failed direct display update must both surface") | 669 | require.Len(t, history, 2, "the layout error and its failed direct display update must both surface") | ||
| @@ -683,12 +683,12 @@ func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) { | |||||
| 683 | sess.tabs = append(sess.tabs, second) | 683 | sess.tabs = append(sess.tabs, second) | ||
| 684 | sess.mu.Unlock() | 684 | sess.mu.Unlock() | ||
| 685 | 685 | ||||
| 686 | - | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2) | 686 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil) |
| 687 | second.mu.Lock() | 687 | second.mu.Lock() | ||
| 688 | require.Equal(t, floatingHidden, second.floating.state) | 688 | require.Equal(t, floatingHidden, second.floating.state) | ||
| 689 | second.mu.Unlock() | 689 | second.mu.Unlock() | ||
| 690 | 690 | ||||
| 691 | - | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab1) | 691 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab1, nil) |
| 692 | first.mu.Lock() | 692 | first.mu.Lock() | ||
| 693 | require.Equal(t, floatingVisible, first.floating.state) | 693 | require.Equal(t, floatingVisible, first.floating.state) | ||
| 694 | first.mu.Unlock() | 694 | first.mu.Unlock() | ||
M internal/usecase/daemon/navigation_overflow_test.go +6 -6
| @@ -69,7 +69,7 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) { | |||||
| 69 | target.mu.Unlock() | 69 | target.mu.Unlock() | ||
| 70 | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | 70 | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | ||
| 71 | 71 | ||||
| 72 | - | daemonKeyHandler{d: d, ac: ac}.Action(tt.action) | 72 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil) |
| 73 | 73 | ||||
| 74 | require.Equal(t, tt.wantActive, activeTabIndex(sess)) | 74 | require.Equal(t, tt.wantActive, activeTabIndex(sess)) | ||
| 75 | target.mu.Lock() | 75 | target.mu.Lock() | ||
| @@ -124,7 +124,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||||
| 124 | } | 124 | } | ||
| 125 | for _, move := range moves { | 125 | for _, move := range moves { | ||
| 126 | t.Run(move.name, func(t *testing.T) { | 126 | t.Run(move.name, func(t *testing.T) { | ||
| 127 | - | handler.Action(move.action) | 127 | + | handler.Action(move.action, nil) |
| 128 | require.Same(t, move.want, ac.currentSession()) | 128 | require.Same(t, move.want, ac.currentSession()) | ||
| 129 | }) | 129 | }) | ||
| 130 | } | 130 | } | ||
| @@ -168,7 +168,7 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | |||||
| 168 | d.mu.Unlock() | 168 | d.mu.Unlock() | ||
| 169 | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | 169 | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | ||
| 170 | 170 | ||||
| 171 | - | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown) | 171 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown, nil) |
| 172 | 172 | ||||
| 173 | require.Same(t, alpha, ac.currentSession()) | 173 | require.Same(t, alpha, ac.currentSession()) | ||
| 174 | alpha.mu.Lock() | 174 | alpha.mu.Lock() | ||
| @@ -296,8 +296,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||||
| 296 | close(start) | 296 | close(start) | ||
| 297 | handler := daemonKeyHandler{d: d, ac: ac} | 297 | handler := daemonKeyHandler{d: d, ac: ac} | ||
| 298 | for range 20 { | 298 | for range 20 { | ||
| 299 | - | handler.Action(keys.ActionFocusPaneDown) | 299 | + | handler.Action(keys.ActionFocusPaneDown, nil) |
| 300 | - | handler.Action(keys.ActionFocusPaneUp) | 300 | + | handler.Action(keys.ActionFocusPaneUp, nil) |
| 301 | } | 301 | } | ||
| 302 | wg.Wait() | 302 | wg.Wait() | ||
| 303 | close(renameErrors) | 303 | close(renameErrors) | ||
| @@ -349,7 +349,7 @@ func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t | |||||
| 349 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | 349 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | ||
| 350 | tt.setup(d, sess) | 350 | tt.setup(d, sess) | ||
| 351 | 351 | ||||
| 352 | - | daemonKeyHandler{d: d, ac: ac}.Action(tt.dir) | 352 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.dir, nil) |
| 353 | 353 | ||||
| 354 | require.Equal(t, 0, activeTabIndex(sess)) | 354 | require.Equal(t, 0, activeTabIndex(sess)) | ||
| 355 | }) | 355 | }) | ||
M internal/usecase/daemon/notices_overlay.go +16 -10
| @@ -17,7 +17,7 @@ var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight | |||||
| 17 | 17 | ||||
| 18 | // enterNotices opens the notification history overlay from the daemon's full | 18 | // enterNotices opens the notification history overlay from the daemon's full | ||
| 19 | // notice center history, newest first. | 19 | // notice center history, newest first. | ||
| 20 | - | func (d *Daemon) enterNotices(sess *session, ac *attachedClient) { | 20 | + | func (d *Daemon) enterNotices(sess attachmentSession, ac *attachedClient) { |
| 21 | history := d.notices.history() | 21 | history := d.notices.history() | ||
| 22 | now := d.clock.Now() | 22 | now := d.clock.Now() | ||
| 23 | ac.overlays.noticeMu.Lock() | 23 | ac.overlays.noticeMu.Lock() | ||
| @@ -172,16 +172,16 @@ func (d *Daemon) noticesListInputState(ac *attachedClient) listInputState { | |||||
| 172 | rt.noticesOverlay = nil | 172 | rt.noticesOverlay = nil | ||
| 173 | }, | 173 | }, | ||
| 174 | afterClose: func() { | 174 | afterClose: func() { | ||
| 175 | - | if sess := ac.currentSession(); sess != nil { | 175 | + | if entry := ac.currentAttachmentSession(); entry != nil { |
| 176 | - | d.invalidateRender(sess, ac, true, "notices_overlay.go") | 176 | + | d.invalidateRender(entry, ac, true, "notices_overlay.go") |
| 177 | } | 177 | } | ||
| 178 | }, | 178 | }, | ||
| 179 | } | 179 | } | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | 182 | func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | ||
| 183 | - | sess := ac.currentSession() | 183 | + | entry := ac.currentAttachmentSession() |
| 184 | - | if sess == nil { | 184 | + | if entry == nil { |
| 185 | return | 185 | return | ||
| 186 | } | 186 | } | ||
| 187 | rt := ac.overlays | 187 | rt := ac.overlays | ||
| @@ -210,10 +210,10 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | |||||
| 210 | // yankNotice sends over the wire and repaints itself, so it must run | 210 | // yankNotice sends over the wire and repaints itself, so it must run | ||
| 211 | // with noticeMu released; a quick yank doesn't close the overlay the | 211 | // with noticeMu released; a quick yank doesn't close the overlay the | ||
| 212 | // way copy mode's own 'y' commits and exits. | 212 | // way copy mode's own 'y' commits and exits. | ||
| 213 | - | d.yankNotice(sess, ac, yankTarget) | 213 | + | d.yankNotice(entry, ac, yankTarget) |
| 214 | } | 214 | } | ||
| 215 | if result.exit || result.changed { | 215 | if result.exit || result.changed { | ||
| 216 | - | d.invalidateRender(sess, ac, true, "notices_overlay.go") | 216 | + | d.invalidateRender(entry, ac, true, "notices_overlay.go") |
| 217 | } | 217 | } | ||
| 218 | } | 218 | } | ||
| 219 | 219 | ||||
| @@ -221,12 +221,18 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | |||||
| 221 | // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput): | 221 | // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput): | ||
| 222 | // send each chunk, then leave a one-shot status-bar confirmation that the | 222 | // send each chunk, then leave a one-shot status-bar confirmation that the | ||
| 223 | // next repaint clears. | 223 | // next repaint clears. | ||
| 224 | - | func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notification) { | 224 | + | func (d *Daemon) yankNotice(entry attachmentSession, ac *attachedClient, n domain.Notification) { |
| 225 | chunks := scopy.OSC52(noticeYankPayload(n)) | 225 | chunks := scopy.OSC52(noticeYankPayload(n)) | ||
| 226 | for _, chunk := range chunks { | 226 | for _, chunk := range chunks { | ||
| 227 | failed, err := d.boundedSendOutputErrTransport(ac, chunk) | 227 | failed, err := d.boundedSendOutputErrTransport(ac, chunk) | ||
| 228 | if err != nil { | 228 | if err != nil { | ||
| 229 | - | d.detachOnSendError(sess, ac, failed) | 229 | + | if sess, ok := localSession(entry); ok { |
| 230 | + | d.detachOnSendError(sess, ac, failed) | |||
| 231 | + | } else if proxy, ok := entry.(*proxySession); ok { | |||
| 232 | + | d.detachProxyOnSendError(proxy, ac, failed) | |||
| 233 | + | } else { | |||
| 234 | + | _ = ac.closeCapturedTransport(failed) | |||
| 235 | + | } | |||
| 230 | return | 236 | return | ||
| 231 | } | 237 | } | ||
| 232 | } | 238 | } | ||
| @@ -237,7 +243,7 @@ func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notifica | |||||
| 237 | ac.overlays.statusFeedback = "notification too large to copy" | 243 | ac.overlays.statusFeedback = "notification too large to copy" | ||
| 238 | } | 244 | } | ||
| 239 | ac.overlays.copyMu.Unlock() | 245 | ac.overlays.copyMu.Unlock() | ||
| 240 | - | d.invalidateRender(sess, ac, true, "notices_overlay.go") | 246 | + | d.invalidateRender(entry, ac, true, "notices_overlay.go") |
| 241 | } | 247 | } | ||
| 242 | 248 | ||||
| 243 | // noticeYankPayload formats n the way the yank commands put it on the | 249 | // noticeYankPayload formats n the way the yank commands put it on the | ||
M internal/usecase/daemon/notices_overlay_test.go +34 -0
| @@ -431,6 +431,40 @@ func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) { | |||||
| 431 | require.Equal(t, "no notifications yet", history[0].Message) | 431 | require.Equal(t, "no notifications yet", history[0].Message) | ||
| 432 | } | 432 | } | ||
| 433 | 433 | ||||
| 434 | + | func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) { | |||
| 435 | + | d, proxy, ac, _, handler := newProxyInputHarness(t) | |||
| 436 | + | handler.enterPalette() | |||
| 437 | + | d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect) | |||
| 438 | + | ||||
| 439 | + | history := d.notices.history() | |||
| 440 | + | require.Len(t, history, 1) | |||
| 441 | + | require.Equal(t, domain.SessionID(""), history[0].SessionID) | |||
| 442 | + | require.Same(t, proxy, ac.currentAttachmentSession()) | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) { | |||
| 446 | + | d, proxy, ac, _, handler := newProxyInputHarness(t) | |||
| 447 | + | handler.roleToken.effect.End() | |||
| 448 | + | client, ok := ac.transport().(*proxyTestTransport) | |||
| 449 | + | require.True(t, ok) | |||
| 450 | + | client.sendFails.Store(true) | |||
| 451 | + | ||||
| 452 | + | d.yankNotice(proxy, ac, domain.Notification{Code: domain.NoticeClipboard, Message: "copy", Time: time.Unix(1, 0)}) | |||
| 453 | + | ||||
| 454 | + | require.Nil(t, ac.currentAttachmentSession()) | |||
| 455 | + | select { | |||
| 456 | + | case <-client.closed: | |||
| 457 | + | default: | |||
| 458 | + | t.Fatal("proxy client transport was not closed") | |||
| 459 | + | } | |||
| 460 | + | proxy.mu.Lock() | |||
| 461 | + | warm := proxy.warm | |||
| 462 | + | proxy.mu.Unlock() | |||
| 463 | + | require.NotNil(t, warm, "proxy send failure must retain one warm expiry path") | |||
| 464 | + | warm.stop() | |||
| 465 | + | awaitTestCompletion(t, warm.done, "warm expiry did not stop during cleanup") | |||
| 466 | + | } | |||
| 467 | + | ||||
| 434 | func TestNoticesYKeyYanksSelectedNotification(t *testing.T) { | 468 | func TestNoticesYKeyYanksSelectedNotification(t *testing.T) { | ||
| 435 | p, release := newBlockingPTY(t) | 469 | p, release := newBlockingPTY(t) | ||
| 436 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 470 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
M internal/usecase/daemon/notify.go +14 -6
| @@ -229,14 +229,18 @@ func (d *Daemon) reportError(sess *session, err error) { | |||||
| 229 | if err == nil || benignNoticeError(err) { | 229 | if err == nil || benignNoticeError(err) { | ||
| 230 | return | 230 | return | ||
| 231 | } | 231 | } | ||
| 232 | - | var ue *domain.UserError | 232 | + | if ue, ok := errors.AsType[*domain.UserError](err); ok { |
| 233 | - | if errors.As(err, &ue) { | |||
| 234 | d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err) | 233 | d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err) | ||
| 235 | return | 234 | return | ||
| 236 | } | 235 | } | ||
| 237 | d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err) | 236 | d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err) | ||
| 238 | } | 237 | } | ||
| 239 | 238 | ||||
| 239 | + | func (d *Daemon) reportAttachmentError(entry attachmentSession, err error) { | |||
| 240 | + | local, _ := localSession(entry) | |||
| 241 | + | d.reportError(local, err) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 240 | // notify records a notice and routes it to whoever can see it. A nil sess means | 244 | // notify records a notice and routes it to whoever can see it. A nil sess means | ||
| 241 | // daemon-global: every attached client gets it, and if none is attached the | 245 | // daemon-global: every attached client gets it, and if none is attached the | ||
| 242 | // notice waits in the pending queue for the next attach. A detached session's | 246 | // notice waits in the pending queue for the next attach. A detached session's | ||
| @@ -293,7 +297,10 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||||
| 293 | func (d *Daemon) deliverGlobal(n domain.Notification) { | 297 | func (d *Daemon) deliverGlobal(n domain.Notification) { | ||
| 294 | d.mu.Lock() | 298 | d.mu.Lock() | ||
| 295 | d.notices.routingMu.Lock() | 299 | d.notices.routingMu.Lock() | ||
| 296 | - | sessions := localSessionsSnapshot(d.sessions) | 300 | + | sessions := make([]attachmentSession, 0, len(d.sessions)) |
| 301 | + | for _, entry := range d.sessions { | |||
| 302 | + | sessions = append(sessions, entry) | |||
| 303 | + | } | |||
| 297 | d.mu.Unlock() | 304 | d.mu.Unlock() | ||
| 298 | 305 | ||||
| 299 | if d.notices.beforeGlobalDelivery != nil { | 306 | if d.notices.beforeGlobalDelivery != nil { | ||
| @@ -301,9 +308,10 @@ func (d *Daemon) deliverGlobal(n domain.Notification) { | |||||
| 301 | } | 308 | } | ||
| 302 | targets := make([]*attachedClient, 0, len(sessions)) | 309 | targets := make([]*attachedClient, 0, len(sessions)) | ||
| 303 | for _, s := range sessions { | 310 | for _, s := range sessions { | ||
| 304 | - | s.mu.Lock() | 311 | + | core := s.core() |
| 305 | - | ac := s.client | 312 | + | core.mu.Lock() |
| 306 | - | s.mu.Unlock() | 313 | + | ac := core.client |
| 314 | + | core.mu.Unlock() | |||
| 307 | if ac == nil || !d.publishToast(ac, n) { | 315 | if ac == nil || !d.publishToast(ac, n) { | ||
| 308 | continue | 316 | continue | ||
| 309 | } | 317 | } | ||
M internal/usecase/daemon/notify_test.go +15 -0
| @@ -18,6 +18,21 @@ import ( | |||||
| 18 | "github.com/bnema/vev/pkg/vt" | 18 | "github.com/bnema/vev/pkg/vt" | ||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | + | func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) { | |||
| 22 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 23 | + | local := &session{sessionCore: sessionCore{id: "local", name: "local"}} | |||
| 24 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize) | |||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | ||||
| 27 | + | d.reportAttachmentError(local, domain.UserWarn(domain.NoticeClipboard, "local failure", nil)) | |||
| 28 | + | d.reportAttachmentError(proxy, domain.UserWarn(domain.NoticeClipboard, "proxy failure", nil)) | |||
| 29 | + | ||||
| 30 | + | history := d.notices.history() | |||
| 31 | + | require.Len(t, history, 2, "each attachment error must report exactly once") | |||
| 32 | + | require.Equal(t, local.id, history[1].SessionID) | |||
| 33 | + | require.Equal(t, domain.SessionID(""), history[0].SessionID) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 21 | func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) { | 36 | func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) { | ||
| 22 | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | 37 | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | ||
| 23 | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback}) | 38 | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback}) | ||
M internal/usecase/daemon/palette.go +141 -17
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "errors" | 5 | "errors" | ||
| 5 | "sort" | 6 | "sort" | ||
| 6 | "time" | 7 | "time" | ||
| @@ -20,6 +21,34 @@ const ( | |||||
| 20 | paletteRailWidth = 64 | 21 | paletteRailWidth = 64 | ||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 24 | + | type proxyPaletteOwnership uint8 | |||
| 25 | + | ||||
| 26 | + | const ( | |||
| 27 | + | proxyPaletteRejected proxyPaletteOwnership = iota | |||
| 28 | + | proxyPaletteLocal | |||
| 29 | + | proxyPaletteRemote | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // proxyPaletteCommandOwnership is exhaustive by policy: an unknown future | |||
| 33 | + | // palette command is rejected until its proxy ownership is deliberately added. | |||
| 34 | + | func proxyPaletteCommandOwnership(slug string) proxyPaletteOwnership { | |||
| 35 | + | switch slug { | |||
| 36 | + | case "new-tab", "close-tab", | |||
| 37 | + | "split-right", "split-left", "split-up", "split-down", | |||
| 38 | + | "consume-or-expel-pane-left", "consume-or-expel-pane-right", | |||
| 39 | + | "stack-pane", "toggle-stack", "toggle-floating-pane", "close-pane", | |||
| 40 | + | "focus-pane-left", "focus-pane-right", "focus-pane-up", "focus-pane-down", | |||
| 41 | + | "resize-pane", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", | |||
| 42 | + | "next-tab", "previous-tab", "rename-tab": | |||
| 43 | + | return proxyPaletteRemote | |||
| 44 | + | case "new-session", "back-session", "jump-recent-session", "session-picker", | |||
| 45 | + | "notifications", "yank-last-notification", "detach": | |||
| 46 | + | return proxyPaletteLocal | |||
| 47 | + | default: | |||
| 48 | + | return proxyPaletteRejected | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 23 | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}} | 52 | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}} | ||
| 24 | 53 | ||||
| 25 | func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal { | 54 | func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal { | ||
| @@ -146,10 +175,12 @@ func (d *Daemon) recordPaletteUse(code string) { | |||||
| 146 | } | 175 | } | ||
| 147 | 176 | ||||
| 148 | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | 177 | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | ||
| 149 | - | sess := ac.currentSession() | 178 | + | entry := ac.currentAttachmentSession() |
| 150 | - | if sess == nil { | 179 | + | if entry == nil { |
| 151 | return | 180 | return | ||
| 152 | } | 181 | } | ||
| 182 | + | sess, local := localSession(entry) | |||
| 183 | + | proxy, remote := entry.(*proxySession) | |||
| 153 | var cmd command.Command | 184 | var cmd command.Command | ||
| 154 | var sessionTarget palette.Result | 185 | var sessionTarget palette.Result | ||
| 155 | var hasSessionTarget bool | 186 | var hasSessionTarget bool | ||
| @@ -252,12 +283,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 252 | } | 283 | } | ||
| 253 | ac.overlays.paletteMu.Unlock() | 284 | ac.overlays.paletteMu.Unlock() | ||
| 254 | if cancel { | 285 | if cancel { | ||
| 255 | - | d.invalidateRender(sess, ac, true, "palette.go") | 286 | + | d.invalidateRender(entry, ac, true, "palette.go") |
| 256 | return | 287 | return | ||
| 257 | } | 288 | } | ||
| 258 | if !execute { | 289 | if !execute { | ||
| 259 | if changed { | 290 | if changed { | ||
| 260 | - | d.invalidateRender(sess, ac, true, "palette.go") | 291 | + | d.invalidateRender(entry, ac, true, "palette.go") |
| 261 | } | 292 | } | ||
| 262 | return | 293 | return | ||
| 263 | } | 294 | } | ||
| @@ -275,19 +306,21 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 275 | var err error | 306 | var err error | ||
| 276 | if effect != nil { | 307 | if effect != nil { | ||
| 277 | err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session") | 308 | err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session") | ||
| 278 | - | } else { | 309 | + | } else if local { |
| 279 | err = d.switchToTarget(sess, ac, target) | 310 | err = d.switchToTarget(sess, ac, target) | ||
| 311 | + | } else { | |||
| 312 | + | err = errAttachmentTransition | |||
| 280 | } | 313 | } | ||
| 281 | - | if effect != nil && errors.Is(err, errAttachmentTransition) { | 314 | + | if errors.Is(err, errAttachmentTransition) { |
| 282 | return | 315 | return | ||
| 283 | } | 316 | } | ||
| 284 | if err != nil { | 317 | if err != nil { | ||
| 285 | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | 318 | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | ||
| 286 | - | d.invalidateRender(sess, ac, true, "palette.go") | 319 | + | d.invalidateRender(entry, ac, true, "palette.go") |
| 287 | return | 320 | return | ||
| 288 | } | 321 | } | ||
| 289 | if ac.closeExecutedPalette(generation, rawQuery) { | 322 | if ac.closeExecutedPalette(generation, rawQuery) { | ||
| 290 | - | d.invalidateRender(ac.currentSession(), ac, true, "palette.go") | 323 | + | d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go") |
| 291 | } | 324 | } | ||
| 292 | return | 325 | return | ||
| 293 | } | 326 | } | ||
| @@ -296,21 +329,87 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 296 | rank, err := command.ParsePositiveDecimal(args) | 329 | rank, err := command.ParsePositiveDecimal(args) | ||
| 297 | if err != nil { | 330 | if err != nil { | ||
| 298 | ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal") | 331 | ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal") | ||
| 299 | - | d.invalidateRender(sess, ac, true, "palette.go") | 332 | + | d.invalidateRender(entry, ac, true, "palette.go") |
| 300 | return | 333 | return | ||
| 301 | } | 334 | } | ||
| 302 | - | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent, effect: effect} | 335 | + | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect} |
| 303 | if err := exec.JumpRecentSession(rank); err != nil { | 336 | if err := exec.JumpRecentSession(rank); err != nil { | ||
| 304 | if errors.Is(err, errAttachmentTransition) { | 337 | if errors.Is(err, errAttachmentTransition) { | ||
| 305 | return | 338 | return | ||
| 306 | } | 339 | } | ||
| 307 | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | 340 | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | ||
| 308 | - | d.invalidateRender(sess, ac, true, "palette.go") | 341 | + | d.invalidateRender(entry, ac, true, "palette.go") |
| 309 | return | 342 | return | ||
| 310 | } | 343 | } | ||
| 311 | if ac.closeExecutedPalette(generation, rawQuery) { | 344 | if ac.closeExecutedPalette(generation, rawQuery) { | ||
| 312 | d.recordPaletteUse(cmd.Code) | 345 | d.recordPaletteUse(cmd.Code) | ||
| 313 | - | d.invalidateRender(ac.currentSession(), ac, true, "palette.go") | 346 | + | d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go") |
| 347 | + | } | |||
| 348 | + | return | |||
| 349 | + | } | |||
| 350 | + | ownership := proxyPaletteCommandOwnership(cmd.Slug) | |||
| 351 | + | if remote && ownership == proxyPaletteRemote { | |||
| 352 | + | if !ac.closeExecutedPalette(generation, rawQuery) { | |||
| 353 | + | return | |||
| 354 | + | } | |||
| 355 | + | ctx := d.serveCtx | |||
| 356 | + | if ctx == nil { | |||
| 357 | + | ctx = context.Background() | |||
| 358 | + | } | |||
| 359 | + | result, err := proxy.sendCommand(ctx, d.clock, cmd.Slug, args) | |||
| 360 | + | if err != nil { | |||
| 361 | + | d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, "remote command failed", err) | |||
| 362 | + | return | |||
| 363 | + | } | |||
| 364 | + | if !result.OK { | |||
| 365 | + | text := result.Text | |||
| 366 | + | if text == "" { | |||
| 367 | + | text = "remote command failed" | |||
| 368 | + | } | |||
| 369 | + | d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, text, nil) | |||
| 370 | + | return | |||
| 371 | + | } | |||
| 372 | + | d.recordPaletteUse(cmd.Code) | |||
| 373 | + | d.invalidateRender(proxy, ac, true, "palette.go") | |||
| 374 | + | return | |||
| 375 | + | } | |||
| 376 | + | if remote && ownership == proxyPaletteLocal { | |||
| 377 | + | // Every local action supersedes this exact remote palette generation. | |||
| 378 | + | // Close it before role handoff or opening another overlay so a delayed | |||
| 379 | + | // action cannot leave the old palette rendered over the new state. | |||
| 380 | + | if !ac.closeExecutedPalette(generation, rawQuery) { | |||
| 381 | + | return | |||
| 382 | + | } | |||
| 383 | + | var err error | |||
| 384 | + | switch cmd.Slug { | |||
| 385 | + | case "new-session", "yank-last-notification": | |||
| 386 | + | err = cmd.Run(paletteExec{d: d, ac: ac, effect: effect, attachment: proxy}, args) | |||
| 387 | + | if err == nil { | |||
| 388 | + | d.recordPaletteUse(cmd.Code) | |||
| 389 | + | } | |||
| 390 | + | case "back-session": | |||
| 391 | + | if effect != nil { | |||
| 392 | + | err = d.backSessionForRole(effect.roleToken()) | |||
| 393 | + | } | |||
| 394 | + | case "detach": | |||
| 395 | + | if effect != nil && !d.clientGoneForRole(effect.roleToken(), true) { | |||
| 396 | + | err = errAttachmentTransition | |||
| 397 | + | } | |||
| 398 | + | case "session-picker": | |||
| 399 | + | d.enterPicker(proxy, ac) | |||
| 400 | + | case "notifications": | |||
| 401 | + | d.enterNotices(proxy, ac) | |||
| 402 | + | default: | |||
| 403 | + | err = errors.New(cmd.Name + " is unavailable for a remote session") | |||
| 404 | + | } | |||
| 405 | + | if err != nil && !errors.Is(err, errAttachmentTransition) { | |||
| 406 | + | d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, err.Error(), nil) | |||
| 407 | + | } | |||
| 408 | + | return | |||
| 409 | + | } | |||
| 410 | + | if !local { | |||
| 411 | + | if ac.closeExecutedPalette(generation, rawQuery) { | |||
| 412 | + | d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, cmd.Name+" is unavailable for this attachment", nil) | |||
| 314 | } | 413 | } | ||
| 315 | return | 414 | return | ||
| 316 | } | 415 | } | ||
| @@ -392,6 +491,7 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin | |||||
| 392 | type paletteExec struct { | 491 | type paletteExec struct { | ||
| 393 | d *Daemon | 492 | d *Daemon | ||
| 394 | sess *session | 493 | sess *session | ||
| 494 | + | attachment attachmentSession | |||
| 395 | ac *attachedClient | 495 | ac *attachedClient | ||
| 396 | recent []recentSession | 496 | recent []recentSession | ||
| 397 | actions daemonActionRunner | 497 | actions daemonActionRunner | ||
| @@ -428,9 +528,17 @@ func (e paletteExec) CreateTab() error { | |||||
| 428 | } | 528 | } | ||
| 429 | 529 | ||||
| 430 | func (e paletteExec) CreateSession() error { | 530 | func (e paletteExec) CreateSession() error { | ||
| 431 | - | e.d.enterTransitionPrompt(e.sess, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error { | 531 | + | entry := e.attachment |
| 532 | + | if entry == nil { | |||
| 533 | + | entry = e.sess | |||
| 534 | + | } | |||
| 535 | + | e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error { | |||
| 432 | if token.ac == nil { | 536 | if token.ac == nil { | ||
| 433 | - | return e.d.createSessionAndSwitch(e.sess, e.ac, name) | 537 | + | sess, ok := e.localSession() |
| 538 | + | if !ok { | |||
| 539 | + | return errAttachmentTransition | |||
| 540 | + | } | |||
| 541 | + | return e.d.createSessionAndSwitch(sess, e.ac, name) | |||
| 434 | } | 542 | } | ||
| 435 | return e.d.createSessionAndSwitchForRole(token, name) | 543 | return e.d.createSessionAndSwitchForRole(token, name) | ||
| 436 | }) | 544 | }) | ||
| @@ -588,18 +696,30 @@ func (e paletteExec) OpenNotifications() error { | |||||
| 588 | } | 696 | } | ||
| 589 | 697 | ||||
| 590 | func (e paletteExec) YankLastNotification() error { | 698 | func (e paletteExec) YankLastNotification() error { | ||
| 699 | + | entry := e.attachment | |||
| 700 | + | if entry == nil { | |||
| 701 | + | entry = e.sess | |||
| 702 | + | } | |||
| 591 | n, ok := e.d.notices.latest() | 703 | n, ok := e.d.notices.latest() | ||
| 592 | if !ok { | 704 | if !ok { | ||
| 593 | // Reported directly rather than returned: the generic cmd.Run error | 705 | // Reported directly rather than returned: the generic cmd.Run error | ||
| 594 | // path only logs, so a returned error here would never reach the | 706 | // path only logs, so a returned error here would never reach the | ||
| 595 | // user as the warn toast this no-op is supposed to produce. | 707 | // user as the warn toast this no-op is supposed to produce. | ||
| 596 | - | e.d.reportError(e.sess, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil)) | 708 | + | e.d.reportAttachmentError(entry, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil)) |
| 597 | return nil | 709 | return nil | ||
| 598 | } | 710 | } | ||
| 599 | - | e.d.yankNotice(e.sess, e.ac, n) | 711 | + | e.d.yankNotice(entry, e.ac, n) |
| 600 | return nil | 712 | return nil | ||
| 601 | } | 713 | } | ||
| 602 | 714 | ||||
| 715 | + | func (e paletteExec) localSession() (*session, bool) { | |||
| 716 | + | entry := e.attachment | |||
| 717 | + | if entry == nil { | |||
| 718 | + | entry = e.sess | |||
| 719 | + | } | |||
| 720 | + | return localSession(entry) | |||
| 721 | + | } | |||
| 722 | + | ||||
| 603 | func (e paletteExec) JumpRecentSession(rank int) error { | 723 | func (e paletteExec) JumpRecentSession(rank int) error { | ||
| 604 | if rank < 1 || rank > len(e.recent) { | 724 | if rank < 1 || rank > len(e.recent) { | ||
| 605 | return command.ErrInvalidArguments | 725 | return command.ErrInvalidArguments | ||
| @@ -615,7 +735,11 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||||
| 615 | if e.effect != nil { | 735 | if e.effect != nil { | ||
| 616 | err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session") | 736 | err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session") | ||
| 617 | } else { | 737 | } else { | ||
| 618 | - | err = e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | 738 | + | sess, ok := e.localSession() |
| 739 | + | if !ok { | |||
| 740 | + | return errAttachmentTransition | |||
| 741 | + | } | |||
| 742 | + | err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | |||
| 619 | } | 743 | } | ||
| 620 | if e.effect != nil && errors.Is(err, errAttachmentTransition) { | 744 | if e.effect != nil && errors.Is(err, errAttachmentTransition) { | ||
| 621 | return err | 745 | return err | ||
M internal/usecase/daemon/palette_test.go +22 -0
| @@ -125,6 +125,28 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||||
| 125 | // only a log line. The new-tab spawn failure is wrapped as a domain.UserError | 125 | // only a log line. The new-tab spawn failure is wrapped as a domain.UserError | ||
| 126 | // with NoticeTabSpawn (session.go createTab), so it surfaces under that code | 126 | // with NoticeTabSpawn (session.go createTab), so it surfaces under that code | ||
| 127 | // rather than the NoticeInternal catch-all. | 127 | // rather than the NoticeInternal catch-all. | ||
| 128 | + | func TestRemotePaletteClosesBeforeOpeningLocalOverlay(t *testing.T) { | |||
| 129 | + | tests := []struct { | |||
| 130 | + | name string | |||
| 131 | + | code string | |||
| 132 | + | active func(*overlayRuntime) bool | |||
| 133 | + | }{ | |||
| 134 | + | {name: "session picker", code: "SSP", active: func(rt *overlayRuntime) bool { return rt.pickerActive() }}, | |||
| 135 | + | {name: "notifications", code: "NTC", active: func(rt *overlayRuntime) bool { return rt.noticesActive() }}, | |||
| 136 | + | } | |||
| 137 | + | for _, test := range tests { | |||
| 138 | + | t.Run(test.name, func(t *testing.T) { | |||
| 139 | + | d, _, ac, link, handler := newProxyInputHarness(t) | |||
| 140 | + | handler.enterPalette() | |||
| 141 | + | d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.roleToken.effect) | |||
| 142 | + | ||||
| 143 | + | require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active") | |||
| 144 | + | require.True(t, test.active(ac.overlays), "replacement local overlay was not opened") | |||
| 145 | + | requireNoProxyFrame(t, link) | |||
| 146 | + | }) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 128 | func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) { | 150 | func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) { | ||
| 129 | p, release := newBlockingPTY(t) | 151 | p, release := newBlockingPTY(t) | ||
| 130 | defer release() | 152 | defer release() | ||
M internal/usecase/daemon/pane_rearrange_test.go +2 -2
| @@ -338,7 +338,7 @@ func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t * | |||||
| 338 | 338 | ||||
| 339 | paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy}) | 339 | paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy}) | ||
| 340 | controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy}) | 340 | controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy}) | ||
| 341 | - | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction) | 341 | + | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction, nil) |
| 342 | 342 | ||||
| 343 | if outcome.wantErr == nil { | 343 | if outcome.wantErr == nil { | ||
| 344 | require.NoError(t, paletteErr) | 344 | require.NoError(t, paletteErr) | ||
| @@ -394,7 +394,7 @@ func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) { | |||||
| 394 | { | 394 | { | ||
| 395 | name: "key remains silent", | 395 | name: "key remains silent", | ||
| 396 | invoke: func(h *paneRearrangeHarness, ac *attachedClient) error { | 396 | invoke: func(h *paneRearrangeHarness, ac *attachedClient) error { | ||
| 397 | - | daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft) | 397 | + | daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft, nil) |
| 398 | return nil | 398 | return nil | ||
| 399 | }, | 399 | }, | ||
| 400 | }, | 400 | }, | ||
M internal/usecase/daemon/picker.go +168 -18
| @@ -51,12 +51,12 @@ func (d *Daemon) togglePickerSort() { | |||||
| 51 | // enterPicker preserves the existing navigation entry point. Navigation always | 51 | // enterPicker preserves the existing navigation entry point. Navigation always | ||
| 52 | // publishes its model, including an empty one; only move entry can fail for a | 52 | // publishes its model, including an empty one; only move entry can fail for a | ||
| 53 | // missing destination. | 53 | // missing destination. | ||
| 54 | - | func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | 54 | + | func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) { |
| 55 | model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | 55 | model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | ||
| 56 | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | 56 | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | ||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | - | func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) { | 59 | + | func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) { |
| 60 | rt := ac.overlays | 60 | rt := ac.overlays | ||
| 61 | rt.pickerMu.Lock() | 61 | rt.pickerMu.Lock() | ||
| 62 | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | 62 | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | ||
| @@ -83,7 +83,7 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||||
| 83 | 83 | ||||
| 84 | // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally | 84 | // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally | ||
| 85 | // knows nothing about picker intent; picker.New owns all destination policy. | 85 | // knows nothing about picker intent; picker.New owns all destination policy. | ||
| 86 | - | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) { | 86 | + | func (d *Daemon) pickerViews(cur attachmentSession) ([]picker.SessionView, picker.SourceFilter) { |
| 87 | d.mu.Lock() | 87 | d.mu.Lock() | ||
| 88 | sessions := make([]attachmentSession, 0, len(d.sessions)) | 88 | sessions := make([]attachmentSession, 0, len(d.sessions)) | ||
| 89 | proxies := make(map[domain.SessionID]*proxySession) | 89 | proxies := make(map[domain.SessionID]*proxySession) | ||
| @@ -120,8 +120,13 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||||
| 120 | var current picker.SourceFilter | 120 | var current picker.SourceFilter | ||
| 121 | for _, entry := range sessions { | 121 | for _, entry := range sessions { | ||
| 122 | snap := entry.snapshotView(opts) | 122 | snap := entry.snapshotView(opts) | ||
| 123 | - | if entry == cur && snap.active >= 0 && snap.active < len(snap.tabs) { | 123 | + | if entry == cur { |
| 124 | - | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id} | 124 | + | if proxy, ok := entry.(*proxySession); ok { |
| 125 | + | key := proxy.key | |||
| 126 | + | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, RemoteKey: &key} | |||
| 127 | + | } else if snap.active >= 0 && snap.active < len(snap.tabs) { | |||
| 128 | + | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id} | |||
| 129 | + | } | |||
| 125 | } | 130 | } | ||
| 126 | live = append(live, liveSnapshot{entry: entry, view: snap}) | 131 | live = append(live, liveSnapshot{entry: entry, view: snap}) | ||
| 127 | } | 132 | } | ||
| @@ -196,7 +201,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||||
| 196 | return views, current | 201 | return views, current | ||
| 197 | } | 202 | } | ||
| 198 | 203 | ||||
| 199 | - | func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | 204 | + | func (d *Daemon) newPickerModel(cur attachmentSession, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { |
| 200 | views, attachedCurrent := d.pickerViews(cur) | 205 | views, attachedCurrent := d.pickerViews(cur) | ||
| 201 | if intent == pickerNavigate && current == (picker.SourceFilter{}) { | 206 | if intent == pickerNavigate && current == (picker.SourceFilter{}) { | ||
| 202 | current = attachedCurrent | 207 | current = attachedCurrent | ||
| @@ -236,7 +241,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||||
| 236 | afterClose: func() { | 241 | afterClose: func() { | ||
| 237 | d.clearPreviewGeneration(ac, previewGeneration) | 242 | d.clearPreviewGeneration(ac, previewGeneration) | ||
| 238 | d.remotePickerClosed(instance) | 243 | d.remotePickerClosed(instance) | ||
| 239 | - | if sess := ac.currentSession(); sess != nil { | 244 | + | if sess := ac.currentAttachmentSession(); sess != nil { |
| 240 | d.invalidateRender(sess, ac, true, "picker.go") | 245 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 241 | } | 246 | } | ||
| 242 | }, | 247 | }, | ||
| @@ -244,7 +249,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||||
| 244 | } | 249 | } | ||
| 245 | 250 | ||||
| 246 | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | 251 | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | ||
| 247 | - | sess := ac.currentSession() | 252 | + | sess := ac.currentAttachmentSession() |
| 248 | if sess == nil { | 253 | if sess == nil { | ||
| 249 | return | 254 | return | ||
| 250 | } | 255 | } | ||
| @@ -301,7 +306,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 301 | err = d.killPickerTarget(target) | 306 | err = d.killPickerTarget(target) | ||
| 302 | } | 307 | } | ||
| 303 | if err != nil { | 308 | if err != nil { | ||
| 304 | - | d.reportError(sess, err) | 309 | + | d.reportAttachmentError(sess, err) |
| 305 | } | 310 | } | ||
| 306 | } | 311 | } | ||
| 307 | if effect != nil && effect.ended.Load() { | 312 | if effect != nil && effect.ended.Load() { | ||
| @@ -325,7 +330,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 325 | if committing { | 330 | if committing { | ||
| 326 | if intent == pickerMovePane || intent == pickerMoveTab { | 331 | if intent == pickerMovePane || intent == pickerMoveTab { | ||
| 327 | if err := d.movePickerSourceError(source); err != nil { | 332 | if err := d.movePickerSourceError(source); err != nil { | ||
| 328 | - | d.reportError(sess, err) | 333 | + | d.reportAttachmentError(sess, err) |
| 329 | d.invalidateRender(sess, ac, true, "picker.go") | 334 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 330 | return | 335 | return | ||
| 331 | } | 336 | } | ||
| @@ -335,7 +340,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 335 | d.closePicker(ac) | 340 | d.closePicker(ac) | ||
| 336 | err := d.commitMovePickerSelection(intent, source, target) | 341 | err := d.commitMovePickerSelection(intent, source, target) | ||
| 337 | if err != nil { | 342 | if err != nil { | ||
| 338 | - | d.reportError(sess, movePickerUserError(err)) | 343 | + | d.reportAttachmentError(sess, movePickerUserError(err)) |
| 339 | d.invalidateRender(sess, ac, true, "picker.go") | 344 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 340 | return | 345 | return | ||
| 341 | } | 346 | } | ||
| @@ -345,15 +350,17 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 345 | var err error | 350 | var err error | ||
| 346 | if len(effects) != 0 && effects[0] != nil { | 351 | if len(effects) != 0 && effects[0] != nil { | ||
| 347 | err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | 352 | err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | ||
| 348 | - | } else { | 353 | + | } else if local, ok := localSession(sess); ok { |
| 349 | d.closePicker(ac) | 354 | d.closePicker(ac) | ||
| 350 | - | err = d.switchToTarget(sess, ac, target) | 355 | + | err = d.switchToTarget(local, ac, target) |
| 356 | + | } else { | |||
| 357 | + | err = errAttachmentTransition | |||
| 351 | } | 358 | } | ||
| 352 | if errors.Is(err, errAttachmentTransition) { | 359 | if errors.Is(err, errAttachmentTransition) { | ||
| 353 | return | 360 | return | ||
| 354 | } | 361 | } | ||
| 355 | if err != nil { | 362 | if err != nil { | ||
| 356 | - | d.reportError(sess, err) | 363 | + | d.reportAttachmentError(sess, err) |
| 357 | return | 364 | return | ||
| 358 | } | 365 | } | ||
| 359 | return | 366 | return | ||
| @@ -403,13 +410,13 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||||
| 403 | // A same-session preview is already invalidated by its own coordinator. | 410 | // A same-session preview is already invalidated by its own coordinator. | ||
| 404 | // Cross-session previews subscribe to the target's producer wake instead | 411 | // Cross-session previews subscribe to the target's producer wake instead | ||
| 405 | // of starting a direct paint/timer path. | 412 | // of starting a direct paint/timer path. | ||
| 406 | - | if targetSess == ac.currentSession() { | 413 | + | if targetSess == ac.currentAttachmentSession() { |
| 407 | return | 414 | return | ||
| 408 | } | 415 | } | ||
| 409 | rc := d.attachCoordinator(targetSess, nil, nil, false) | 416 | rc := d.attachCoordinator(targetSess, nil, nil, false) | ||
| 410 | rc.subscribePreviewFor(ac, generation, func(renderWake) { | 417 | rc.subscribePreviewFor(ac, generation, func(renderWake) { | ||
| 411 | if pickerPreviewCurrent(ac, targetSess, next, generation) { | 418 | if pickerPreviewCurrent(ac, targetSess, next, generation) { | ||
| 412 | - | if owner := ac.currentSession(); owner != nil { | 419 | + | if owner := ac.currentAttachmentSession(); owner != nil { |
| 413 | d.invalidateRender(owner, ac, false, "picker preview") | 420 | d.invalidateRender(owner, ac, false, "picker preview") | ||
| 414 | } | 421 | } | ||
| 415 | } | 422 | } | ||
| @@ -497,7 +504,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||||
| 497 | observed := ac.overlays.pickerPreview == tb | 504 | observed := ac.overlays.pickerPreview == tb | ||
| 498 | ac.overlays.pickerMu.Unlock() | 505 | ac.overlays.pickerMu.Unlock() | ||
| 499 | if observed && d.clearPreviewGeneration(ac, generation) { | 506 | if observed && d.clearPreviewGeneration(ac, generation) { | ||
| 500 | - | if sess := ac.currentSession(); sess != nil { | 507 | + | if sess := ac.currentAttachmentSession(); sess != nil { |
| 501 | d.invalidateRender(sess, ac, true, "picker.go") | 508 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 502 | } | 509 | } | ||
| 503 | } | 510 | } | ||
| @@ -535,7 +542,8 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g | |||||
| 535 | 542 | ||||
| 536 | func pickerTargetsEqual(left, right picker.Target) bool { | 543 | func pickerTargetsEqual(left, right picker.Target) bool { | ||
| 537 | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || | 544 | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || | ||
| 538 | - | left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped { | 545 | + | left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped || |
| 546 | + | !remoteKeysEqual(left.RemoteKey, right.RemoteKey) { | |||
| 539 | return false | 547 | return false | ||
| 540 | } | 548 | } | ||
| 541 | if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil { | 549 | if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil { | ||
| @@ -544,6 +552,13 @@ func pickerTargetsEqual(left, right picker.Target) bool { | |||||
| 544 | return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt | 552 | return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt | ||
| 545 | } | 553 | } | ||
| 546 | 554 | ||||
| 555 | + | func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool { | |||
| 556 | + | if left == nil || right == nil { | |||
| 557 | + | return left == nil && right == nil | |||
| 558 | + | } | |||
| 559 | + | return *left == *right | |||
| 560 | + | } | |||
| 561 | + | ||||
| 547 | func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession { | 562 | func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession { | ||
| 548 | d.mu.Lock() | 563 | d.mu.Lock() | ||
| 549 | defer d.mu.Unlock() | 564 | defer d.mu.Unlock() | ||
| @@ -615,6 +630,9 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker. | |||||
| 615 | } | 630 | } | ||
| 616 | token.effect.bindActionEnd(d, action) | 631 | token.effect.bindActionEnd(d, action) | ||
| 617 | token.effect.End() | 632 | token.effect.End() | ||
| 633 | + | if target.RemoteKey != nil { | |||
| 634 | + | return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action) | |||
| 635 | + | } | |||
| 618 | sess, ok := localSession(token.sess) | 636 | sess, ok := localSession(token.sess) | ||
| 619 | if !ok { | 637 | if !ok { | ||
| 620 | return errAttachmentTransition | 638 | return errAttachmentTransition | ||
| @@ -622,6 +640,134 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker. | |||||
| 622 | return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action) | 640 | return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action) | ||
| 623 | } | 641 | } | ||
| 624 | 642 | ||||
| 643 | + | // switchToRemoteTargetForRole performs dialing after the initiating effect has | |||
| 644 | + | // ended and without role or architecture locks. transitionAttachment then | |||
| 645 | + | // freezes and revalidates the exact source role and exact structured-key proxy | |||
| 646 | + | // before publishing ownership. | |||
| 647 | + | func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error { | |||
| 648 | + | if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" { | |||
| 649 | + | return errAttachmentTransition | |||
| 650 | + | } | |||
| 651 | + | ctx := d.serveCtx | |||
| 652 | + | if ctx == nil { | |||
| 653 | + | ctx = context.Background() | |||
| 654 | + | } | |||
| 655 | + | // Only the caller that observed neither a published proxy nor an in-flight | |||
| 656 | + | // constructor may own failure cleanup. Waiters and existing-proxy users must | |||
| 657 | + | // never tear down the shared link when their source transition goes stale. | |||
| 658 | + | d.mu.Lock() | |||
| 659 | + | _, alreadyPublished := d.sessions[key.ID()] | |||
| 660 | + | constructionInFlight := d.proxyConstructions[key] != nil | |||
| 661 | + | d.mu.Unlock() | |||
| 662 | + | ownsCandidate := !alreadyPublished && !constructionInFlight | |||
| 663 | + | proxy, err := d.openProxySession(ctx, key, token.ac.size) | |||
| 664 | + | if err != nil { | |||
| 665 | + | if !token.activeCurrent() { | |||
| 666 | + | return errAttachmentTransition | |||
| 667 | + | } | |||
| 668 | + | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't connect to that remote session", err) | |||
| 669 | + | } | |||
| 670 | + | if proxy == nil || proxy.key != key || proxy.id != key.ID() { | |||
| 671 | + | return errAttachmentTransition | |||
| 672 | + | } | |||
| 673 | + | proxy.mu.Lock() | |||
| 674 | + | candidateGeneration := proxy.generation | |||
| 675 | + | proxy.mu.Unlock() | |||
| 676 | + | ||||
| 677 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |||
| 678 | + | source: token.sess, target: proxy, next: token.ac, | |||
| 679 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |||
| 680 | + | expectedTransport: token.transport, sourceToken: &token, action: action, | |||
| 681 | + | preserveRole: proxy == token.sess, ready: true, | |||
| 682 | + | }) | |||
| 683 | + | if err != nil { | |||
| 684 | + | if ownsCandidate { | |||
| 685 | + | d.discardUnattachedProxyGeneration(proxy, candidateGeneration) | |||
| 686 | + | } | |||
| 687 | + | return errAttachmentTransition | |||
| 688 | + | } | |||
| 689 | + | if guard.closePicker { | |||
| 690 | + | fresh, admitted := token.ac.beginRoleEffect(transition.published) | |||
| 691 | + | if admitted { | |||
| 692 | + | d.closePicker(token.ac) | |||
| 693 | + | fresh.End() | |||
| 694 | + | } | |||
| 695 | + | } | |||
| 696 | + | if proxy != token.sess { | |||
| 697 | + | token.ac.recordPreviousSession(token.sess) | |||
| 698 | + | } | |||
| 699 | + | d.deferAttachmentTransitionCleanups(transition) | |||
| 700 | + | if proxy == token.sess { | |||
| 701 | + | d.invalidateRender(proxy, token.ac, true, "picker.go") | |||
| 702 | + | } else { | |||
| 703 | + | d.firstPaintForTransition(transition.published) | |||
| 704 | + | } | |||
| 705 | + | return nil | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | // discardUnattachedProxyGeneration removes only a transition-owned candidate | |||
| 709 | + | // that never acquired a client. It mirrors normal proxy teardown fencing but | |||
| 710 | + | // deliberately does not arm the five-minute warm lifetime for an attachment | |||
| 711 | + | // that was never published. | |||
| 712 | + | func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generation uint64) bool { | |||
| 713 | + | if d == nil || proxy == nil { | |||
| 714 | + | return false | |||
| 715 | + | } | |||
| 716 | + | d.mu.Lock() | |||
| 717 | + | if d.sessions[proxy.id] != proxy { | |||
| 718 | + | d.mu.Unlock() | |||
| 719 | + | return false | |||
| 720 | + | } | |||
| 721 | + | proxy.sessionCore.mu.Lock() | |||
| 722 | + | if proxy.client != nil { | |||
| 723 | + | proxy.sessionCore.mu.Unlock() | |||
| 724 | + | d.mu.Unlock() | |||
| 725 | + | return false | |||
| 726 | + | } | |||
| 727 | + | proxy.mu.Lock() | |||
| 728 | + | if proxy.generation != generation || proxy.expired || proxy.warm != nil { | |||
| 729 | + | proxy.mu.Unlock() | |||
| 730 | + | proxy.sessionCore.mu.Unlock() | |||
| 731 | + | d.mu.Unlock() | |||
| 732 | + | return false | |||
| 733 | + | } | |||
| 734 | + | proxy.generation++ | |||
| 735 | + | proxy.expired = true | |||
| 736 | + | cancel := proxy.cancel | |||
| 737 | + | transport := proxy.transport | |||
| 738 | + | coordinator := proxy.coordinator.Load() | |||
| 739 | + | proxy.mu.Unlock() | |||
| 740 | + | if !d.unregisterSessionLocked(proxy) { | |||
| 741 | + | proxy.sessionCore.mu.Unlock() | |||
| 742 | + | d.mu.Unlock() | |||
| 743 | + | return false | |||
| 744 | + | } | |||
| 745 | + | proxy.sessionCore.mu.Unlock() | |||
| 746 | + | empty := len(d.sessions) == 0 | |||
| 747 | + | if empty { | |||
| 748 | + | d.closing = true | |||
| 749 | + | } | |||
| 750 | + | d.mu.Unlock() | |||
| 751 | + | ||||
| 752 | + | if cancel != nil { | |||
| 753 | + | cancel() | |||
| 754 | + | } | |||
| 755 | + | if transport != nil { | |||
| 756 | + | _ = transport.Close() | |||
| 757 | + | } | |||
| 758 | + | if coordinator != nil { | |||
| 759 | + | coordinator.beginSessionTeardown().finish() | |||
| 760 | + | coordinator.waitForTimerWorkers() | |||
| 761 | + | } | |||
| 762 | + | proxy.finish() | |||
| 763 | + | if empty { | |||
| 764 | + | d.doneOnce.Do(func() { close(d.done) }) | |||
| 765 | + | } else { | |||
| 766 | + | d.refreshRemoteOpenPickers() | |||
| 767 | + | } | |||
| 768 | + | return true | |||
| 769 | + | } | |||
| 770 | + | ||||
| 625 | // switchToTargetGuarded is retained for daemon-internal and headless callers. | 771 | // switchToTargetGuarded is retained for daemon-internal and headless callers. | ||
| 626 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | 772 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | ||
| 627 | return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "") | 773 | return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "") | ||
| @@ -920,6 +1066,10 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR | |||||
| 920 | if token.ac == nil { | 1066 | if token.ac == nil { | ||
| 921 | return d.killPickerTarget(target) | 1067 | return d.killPickerTarget(target) | ||
| 922 | } | 1068 | } | ||
| 1069 | + | if target.RemoteKey != nil { | |||
| 1070 | + | d.enterRemoteKillConfirmation(token.sess, token.ac, target) | |||
| 1071 | + | return nil | |||
| 1072 | + | } | |||
| 923 | if target.Stopped { | 1073 | if target.Stopped { | ||
| 924 | token.effect.bindActionEnd(d, "picker-delete") | 1074 | token.effect.bindActionEnd(d, "picker-delete") | ||
| 925 | token.effect.End() | 1075 | token.effect.End() | ||
M internal/usecase/daemon/prompt.go +7 -6
| @@ -13,7 +13,7 @@ func promptModalFor(title string) ui.Modal { | |||||
| 13 | return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | 13 | return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | ||
| 14 | } | 14 | } | ||
| 15 | 15 | ||||
| 16 | - | func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) { | 16 | + | func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string) error) { |
| 17 | d.closePrompt(ac) | 17 | d.closePrompt(ac) | ||
| 18 | ac.overlays.promptMu.Lock() | 18 | ac.overlays.promptMu.Lock() | ||
| 19 | ac.overlays.prompt = promptui.New(title, initial) | 19 | ac.overlays.prompt = promptui.New(title, initial) | ||
| @@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s | |||||
| 23 | d.invalidateRender(sess, ac, true, "prompt.go") | 23 | d.invalidateRender(sess, ac, true, "prompt.go") | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | - | func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) { | 26 | + | func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) { |
| 27 | d.closePrompt(ac) | 27 | d.closePrompt(ac) | ||
| 28 | ac.overlays.promptMu.Lock() | 28 | ac.overlays.promptMu.Lock() | ||
| 29 | ac.overlays.prompt = promptui.New(title, initial) | 29 | ac.overlays.prompt = promptui.New(title, initial) | ||
| @@ -47,11 +47,12 @@ func (d *Daemon) closePrompt(ac *attachedClient) { | |||||
| 47 | func promptValidationError(err error) bool { | 47 | func promptValidationError(err error) bool { | ||
| 48 | return errors.Is(err, domain.ErrInvalidSessionName) || | 48 | return errors.Is(err, domain.ErrInvalidSessionName) || | ||
| 49 | errors.Is(err, errSessionNameRequired) || | 49 | errors.Is(err, errSessionNameRequired) || | ||
| 50 | - | errors.Is(err, errSessionNameInUse) | 50 | + | errors.Is(err, errSessionNameInUse) || |
| 51 | + | errors.Is(err, errProxyKillConfirmation) | |||
| 51 | } | 52 | } | ||
| 52 | 53 | ||||
| 53 | func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | 54 | func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | ||
| 54 | - | sess := ac.currentSession() | 55 | + | sess := ac.currentAttachmentSession() |
| 55 | if sess == nil { | 56 | if sess == nil { | ||
| 56 | return | 57 | return | ||
| 57 | } | 58 | } | ||
| @@ -112,13 +113,13 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...* | |||||
| 112 | } | 113 | } | ||
| 113 | ac.overlays.promptMu.Unlock() | 114 | ac.overlays.promptMu.Unlock() | ||
| 114 | if !promptValidationError(err) { | 115 | if !promptValidationError(err) { | ||
| 115 | - | d.reportError(sess, err) | 116 | + | d.reportAttachmentError(sess, err) |
| 116 | } | 117 | } | ||
| 117 | d.invalidateRender(sess, ac, true, "prompt.go") | 118 | d.invalidateRender(sess, ac, true, "prompt.go") | ||
| 118 | return | 119 | return | ||
| 119 | } | 120 | } | ||
| 120 | d.closePrompt(ac) | 121 | d.closePrompt(ac) | ||
| 121 | - | if current := ac.currentSession(); current != nil { | 122 | + | if current := ac.currentAttachmentSession(); current != nil { |
| 122 | d.invalidateRender(current, ac, true, "prompt.go") | 123 | d.invalidateRender(current, ac, true, "prompt.go") | ||
| 123 | } | 124 | } | ||
| 124 | return | 125 | return | ||
A internal/usecase/daemon/proxy_input.go +262 -0
| @@ -0,0 +1,262 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/mouse" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/palette" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // sendInput is the sole proxy input sender. Sequence assignment and transport | |||
| 15 | + | // Send are serialized with every other proxy link write; raw is never rebuilt | |||
| 16 | + | // from an interpreted key action. | |||
| 17 | + | func (p *proxySession) sendInput(raw []byte) error { | |||
| 18 | + | if p == nil || len(raw) == 0 { | |||
| 19 | + | return nil | |||
| 20 | + | } | |||
| 21 | + | data := append([]byte(nil), raw...) | |||
| 22 | + | p.sendMu.Lock() | |||
| 23 | + | defer p.sendMu.Unlock() | |||
| 24 | + | ||||
| 25 | + | p.mu.Lock() | |||
| 26 | + | transport := p.transport | |||
| 27 | + | p.mu.Unlock() | |||
| 28 | + | if transport == nil { | |||
| 29 | + | return errors.New("proxy session: input link is unavailable") | |||
| 30 | + | } | |||
| 31 | + | p.inputNext++ | |||
| 32 | + | return transport.Send(ports.Frame{ | |||
| 33 | + | Type: ports.MsgInput, | |||
| 34 | + | Payload: ports.MarshalInput(ports.Input{ | |||
| 35 | + | InputSeq: p.inputNext, | |||
| 36 | + | Data: data, | |||
| 37 | + | }), | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // proxyKeyHandler pins input ownership while a local client is attached to a | |||
| 42 | + | // proxy. Local UI actions never reach the link. Every remote-owned action is | |||
| 43 | + | // sent using the exact bytes supplied by keys.Router. | |||
| 44 | + | type proxyKeyHandler struct { | |||
| 45 | + | d *Daemon | |||
| 46 | + | proxy *proxySession | |||
| 47 | + | ac *attachedClient | |||
| 48 | + | roleToken attachmentRoleToken | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (h proxyKeyHandler) acquireRoleEffect() (*roleEffectTicket, bool, bool) { | |||
| 52 | + | if h.proxy == nil || h.ac == nil { | |||
| 53 | + | return nil, false, false | |||
| 54 | + | } | |||
| 55 | + | if h.roleToken.ac == nil { | |||
| 56 | + | return nil, false, false | |||
| 57 | + | } | |||
| 58 | + | if h.roleToken.sess != h.proxy || h.roleToken.ac != h.ac { | |||
| 59 | + | return nil, false, false | |||
| 60 | + | } | |||
| 61 | + | if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() { | |||
| 62 | + | return effect, false, true | |||
| 63 | + | } | |||
| 64 | + | effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken) | |||
| 65 | + | if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil { | |||
| 66 | + | h.d.afterDelayedKeyEffectAttempt(admitted) | |||
| 67 | + | } | |||
| 68 | + | if !admitted { | |||
| 69 | + | return nil, false, false | |||
| 70 | + | } | |||
| 71 | + | if h.d != nil && h.d.afterRoleEffectAdmitted != nil { | |||
| 72 | + | token := h.roleToken | |||
| 73 | + | token.effect = effect | |||
| 74 | + | h.d.afterRoleEffectAdmitted(token) | |||
| 75 | + | } | |||
| 76 | + | return effect, true, true | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (h proxyKeyHandler) sendOwned(raw []byte) { | |||
| 80 | + | if len(raw) == 0 { | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | if err := h.proxy.sendInput(raw); err != nil && h.d != nil { | |||
| 84 | + | h.d.log.Warn("proxy input send failed", "host", h.proxy.key.Host, "session", h.proxy.key.Name, "err", err) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (h proxyKeyHandler) send(raw []byte) { | |||
| 89 | + | effect, owned, ok := h.acquireRoleEffect() | |||
| 90 | + | if !ok { | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | if owned { | |||
| 94 | + | defer effect.End() | |||
| 95 | + | } | |||
| 96 | + | h.sendOwned(raw) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (h proxyKeyHandler) Forward(raw []byte) { h.send(raw) } | |||
| 100 | + | ||||
| 101 | + | func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) } | |||
| 102 | + | ||||
| 103 | + | func (h proxyKeyHandler) Action(action keys.Action, raw []byte) { | |||
| 104 | + | effect, owned, ok := h.acquireRoleEffect() | |||
| 105 | + | if !ok { | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | if owned { | |||
| 109 | + | defer effect.End() | |||
| 110 | + | } | |||
| 111 | + | h.roleToken.effect = effect | |||
| 112 | + | switch action { | |||
| 113 | + | case keys.ActionOpenPalette: | |||
| 114 | + | h.enterPalette() | |||
| 115 | + | case keys.ActionJumpAttention: | |||
| 116 | + | if h.proxy.hasRemoteAttention() { | |||
| 117 | + | h.sendOwned(raw) | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | h.jumpLocalAttention() | |||
| 121 | + | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |||
| 122 | + | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |||
| 123 | + | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9, | |||
| 124 | + | keys.ActionFocusPaneLeft, keys.ActionFocusPaneRight, keys.ActionFocusPaneUp, keys.ActionFocusPaneDown, | |||
| 125 | + | keys.ActionToggleFloatingPane, | |||
| 126 | + | keys.ActionGrowPaneWidth, keys.ActionShrinkPaneWidth, | |||
| 127 | + | keys.ActionGrowPaneHeight, keys.ActionShrinkPaneHeight, | |||
| 128 | + | keys.ActionEqualizePanes, | |||
| 129 | + | keys.ActionConsumeOrExpelPaneLeft, keys.ActionConsumeOrExpelPaneRight: | |||
| 130 | + | h.sendOwned(raw) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (p *proxySession) hasRemoteAttention() bool { | |||
| 135 | + | if p == nil { | |||
| 136 | + | return false | |||
| 137 | + | } | |||
| 138 | + | p.mu.Lock() | |||
| 139 | + | defer p.mu.Unlock() | |||
| 140 | + | for _, tab := range p.meta.Tabs { | |||
| 141 | + | if tab.Attention { | |||
| 142 | + | return true | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | return false | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (h proxyKeyHandler) enterPalette() { | |||
| 149 | + | if h.d == nil || h.ac == nil || h.proxy == nil { | |||
| 150 | + | return | |||
| 151 | + | } | |||
| 152 | + | // paletteResults still accepts a local current session. Build its immutable | |||
| 153 | + | // snapshot without one and remove this proxy by its structured opaque ID. | |||
| 154 | + | recent := h.d.recentSessions(h.proxy) | |||
| 155 | + | commands := h.d.paletteCommands() | |||
| 156 | + | results := h.d.paletteResults(nil, commands) | |||
| 157 | + | filtered := results[:0] | |||
| 158 | + | for _, result := range results { | |||
| 159 | + | if id, ok := result.SessionID(); ok && id == h.proxy.id { | |||
| 160 | + | continue | |||
| 161 | + | } | |||
| 162 | + | filtered = append(filtered, result) | |||
| 163 | + | } | |||
| 164 | + | h.ac.overlays.paletteMu.Lock() | |||
| 165 | + | h.ac.overlays.paletteGeneration++ | |||
| 166 | + | h.ac.overlays.palette = palette.New(filtered) | |||
| 167 | + | h.ac.overlays.paletteRecent = recent | |||
| 168 | + | h.ac.overlays.paletteHints = palette.ContextualHints{} | |||
| 169 | + | h.ac.overlays.paletteFeedback = "" | |||
| 170 | + | h.ac.overlays.palettePending = nil | |||
| 171 | + | h.ac.overlays.paletteMu.Unlock() | |||
| 172 | + | h.d.invalidateRender(h.proxy, h.ac, true, "proxy_input.go") | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // jumpLocalAttention handles only the selected-session part of attention | |||
| 176 | + | // navigation. A pending tab on the proxy remains remote-owned; without one, | |||
| 177 | + | // the local daemon may move the client to another local session. | |||
| 178 | + | func (h proxyKeyHandler) jumpLocalAttention() { | |||
| 179 | + | if h.d == nil || h.roleToken.ac == nil || h.roleToken.effect == nil { | |||
| 180 | + | return | |||
| 181 | + | } | |||
| 182 | + | target, ok := h.d.oldestOtherSessionAttention(nil) | |||
| 183 | + | if !ok { | |||
| 184 | + | return | |||
| 185 | + | } | |||
| 186 | + | h.d.mu.Lock() | |||
| 187 | + | targetSession, ok := localSession(h.d.sessions[target.sessionID]) | |||
| 188 | + | h.d.mu.Unlock() | |||
| 189 | + | if !ok { | |||
| 190 | + | return | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | token := h.roleToken | |||
| 194 | + | token.effect.bindActionEnd(h.d, "proxy-jump-attention") | |||
| 195 | + | token.effect.End() | |||
| 196 | + | transition, err := h.d.transitionAttachment(attachmentTransitionRequest{ | |||
| 197 | + | source: h.proxy, target: targetSession, next: h.ac, | |||
| 198 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |||
| 199 | + | expectedTransport: token.transport, sourceToken: &token, | |||
| 200 | + | action: "proxy-jump-attention", activateTargetTab: true, | |||
| 201 | + | targetTabIndex: target.tabIndex, ready: true, | |||
| 202 | + | }) | |||
| 203 | + | if err != nil { | |||
| 204 | + | return | |||
| 205 | + | } | |||
| 206 | + | h.d.touchMRU(targetSession) | |||
| 207 | + | h.ac.recordPreviousSession(h.proxy) | |||
| 208 | + | h.d.deferAttachmentTransitionCleanups(transition) | |||
| 209 | + | h.d.firstPaintForTransition(transition.published) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func proxyMouseOwnedLocally(rt *overlayRuntime) bool { | |||
| 213 | + | // copyActive cannot be reached for proxy attachments through production input | |||
| 214 | + | // routing: copy mode is local-session-only and proxy keys never enter it. | |||
| 215 | + | return rt != nil && (rt.promptActive() || rt.paletteActive() || rt.pickerActive() || | |||
| 216 | + | rt.noticesActive() || rt.resizeModeActive()) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func proxiedNavConfig(cfg domain.NavConfig) domain.NavConfig { | |||
| 220 | + | cfg.OverflowSessions = false | |||
| 221 | + | return cfg | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied } | |||
| 225 | + | ||||
| 226 | + | // focusDirProxied performs ordinary pane and tab overflow on the remote daemon, | |||
| 227 | + | // but never follows OverflowSessions. The local proxy daemon is the only owner | |||
| 228 | + | // allowed to change the selected session. | |||
| 229 | + | func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error { | |||
| 230 | + | target := resolveDaemonActionTarget(sess) | |||
| 231 | + | oldFocus := layout.PaneID("") | |||
| 232 | + | if target.pane != nil { | |||
| 233 | + | oldFocus = target.pane.id | |||
| 234 | + | } | |||
| 235 | + | span, err := d.focusDirAt(sess, target.tab, target.pane, dir) | |||
| 236 | + | if err == nil { | |||
| 237 | + | if ac != nil { | |||
| 238 | + | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "proxy_input.go") | |||
| 239 | + | } | |||
| 240 | + | return nil | |||
| 241 | + | } | |||
| 242 | + | if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, target.tab) { | |||
| 243 | + | return err | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | sess.mu.Lock() | |||
| 247 | + | position, count := sess.active, len(sess.tabs) | |||
| 248 | + | sess.mu.Unlock() | |||
| 249 | + | step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count) | |||
| 250 | + | if step.kind != overflowTabs { | |||
| 251 | + | return errNoNeighbor | |||
| 252 | + | } | |||
| 253 | + | candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta) | |||
| 254 | + | if !ok || !d.commitTabOverflow(sess, candidate) { | |||
| 255 | + | return errNoNeighbor | |||
| 256 | + | } | |||
| 257 | + | d.activateTab(sess, candidate.target) | |||
| 258 | + | if ac != nil { | |||
| 259 | + | d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go") | |||
| 260 | + | } | |||
| 261 | + | return nil | |||
| 262 | + | } | |||
A internal/usecase/daemon/proxy_input_test.go +683 -0
| @@ -0,0 +1,683 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "log/slog" | |||
| 7 | + | "sync" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 16 | + | "github.com/bnema/vev/internal/ports" | |||
| 17 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 18 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 19 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 20 | + | "github.com/bnema/vev/internal/usecase/mouse" | |||
| 21 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | func newProxyInputHarness(t *testing.T) (*Daemon, *proxySession, *attachedClient, *proxyTestTransport, proxyKeyHandler) { | |||
| 25 | + | t.Helper() | |||
| 26 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 27 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24}) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | link := newProxyTestTransport() | |||
| 30 | + | proxy.mu.Lock() | |||
| 31 | + | proxy.transport = link | |||
| 32 | + | proxy.linkGeneration = 1 | |||
| 33 | + | proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build"}}} | |||
| 34 | + | proxy.mu.Unlock() | |||
| 35 | + | ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()} | |||
| 36 | + | ac.initOverlays() | |||
| 37 | + | token := roleEffectForTest(t, attachProxyInputRole(t, d, proxy, ac)) | |||
| 38 | + | return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *attachedClient) attachmentRoleToken { | |||
| 42 | + | t.Helper() | |||
| 43 | + | clientTransport := newProxyTestTransport() | |||
| 44 | + | ac.replaceTransport(clientTransport) | |||
| 45 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | |||
| 46 | + | d.mu.Lock() | |||
| 47 | + | require.True(t, d.registerSessionLocked(proxy)) | |||
| 48 | + | d.mu.Unlock() | |||
| 49 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |||
| 50 | + | target: proxy, next: ac, | |||
| 51 | + | expectedRole: attachmentDetached, targetRole: attachmentActive, | |||
| 52 | + | expectedTransport: ac.transportSnapshot(), ready: true, | |||
| 53 | + | }) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | d.deferAttachmentTransitionCleanups(transition) | |||
| 56 | + | return transition.published | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func roleEffectForTest(t *testing.T, token attachmentRoleToken) attachmentRoleToken { | |||
| 60 | + | t.Helper() | |||
| 61 | + | effect, admitted := token.ac.beginRoleEffect(token) | |||
| 62 | + | require.True(t, admitted) | |||
| 63 | + | token.effect = effect | |||
| 64 | + | t.Cleanup(effect.End) | |||
| 65 | + | return token | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func requireProxyInputFrame(t *testing.T, link *proxyTestTransport, wantSeq uint64, want []byte) { | |||
| 69 | + | t.Helper() | |||
| 70 | + | select { | |||
| 71 | + | case frame := <-link.sent: | |||
| 72 | + | require.Equal(t, ports.MsgInput, frame.Type) | |||
| 73 | + | input, err := ports.UnmarshalInput(frame.Payload) | |||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | require.Equal(t, wantSeq, input.InputSeq) | |||
| 76 | + | require.Equal(t, want, input.Data) | |||
| 77 | + | default: | |||
| 78 | + | t.Fatal("missing proxy input frame") | |||
| 79 | + | } | |||
| 80 | + | select { | |||
| 81 | + | case frame := <-link.sent: | |||
| 82 | + | t.Fatalf("input was sent more than once: %#v", frame) | |||
| 83 | + | default: | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func requireNoProxyFrame(t *testing.T, link *proxyTestTransport) { | |||
| 88 | + | t.Helper() | |||
| 89 | + | select { | |||
| 90 | + | case frame := <-link.sent: | |||
| 91 | + | t.Fatalf("unexpected proxy frame: %#v", frame) | |||
| 92 | + | default: | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestProxyInputForwardsOrdinaryPasteMouseAndEscapeBytesExactlyOnce(t *testing.T) { | |||
| 97 | + | _, _, _, link, handler := newProxyInputHarness(t) | |||
| 98 | + | paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), []byte("a\x1bj\x00")...), ports.BracketedPasteCloseMarker...) | |||
| 99 | + | inputs := []struct { | |||
| 100 | + | name string | |||
| 101 | + | raw []byte | |||
| 102 | + | }{ | |||
| 103 | + | {name: "ordinary keyboard", raw: []byte("hello")}, | |||
| 104 | + | {name: "bracketed paste", raw: paste}, | |||
| 105 | + | {name: "terminal escape", raw: []byte("\x1b[15~")}, | |||
| 106 | + | } | |||
| 107 | + | for i, input := range inputs { | |||
| 108 | + | t.Run(input.name, func(t *testing.T) { | |||
| 109 | + | handler.Forward(input.raw) | |||
| 110 | + | requireProxyInputFrame(t, link, uint64(i+1), input.raw) | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | mouseRaw := []byte("\x1b[<0;12;8M") | |||
| 115 | + | handler.Mouse(mouse.Event{Raw: mouseRaw}) | |||
| 116 | + | requireProxyInputFrame(t, link, 4, mouseRaw) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestProxyInputRemoteActionsForwardOriginalChordWithoutLocalExecution(t *testing.T) { | |||
| 120 | + | remoteActions := []keys.Action{ | |||
| 121 | + | keys.ActionSwitchTab1, keys.ActionSwitchTab9, | |||
| 122 | + | keys.ActionFocusPaneLeft, keys.ActionFocusPaneRight, keys.ActionFocusPaneUp, keys.ActionFocusPaneDown, | |||
| 123 | + | keys.ActionToggleFloatingPane, | |||
| 124 | + | keys.ActionGrowPaneWidth, keys.ActionShrinkPaneWidth, keys.ActionGrowPaneHeight, keys.ActionShrinkPaneHeight, | |||
| 125 | + | keys.ActionEqualizePanes, keys.ActionConsumeOrExpelPaneLeft, keys.ActionConsumeOrExpelPaneRight, | |||
| 126 | + | } | |||
| 127 | + | for _, action := range remoteActions { | |||
| 128 | + | t.Run(action.Name(), func(t *testing.T) { | |||
| 129 | + | _, proxy, _, link, handler := newProxyInputHarness(t) | |||
| 130 | + | before := proxy.snapshotView(viewOptions{tabDetails: true}) | |||
| 131 | + | raw := []byte{keys.ESC, byte('A' + action%26), 0x00} | |||
| 132 | + | handler.Action(action, raw) | |||
| 133 | + | requireProxyInputFrame(t, link, 1, raw) | |||
| 134 | + | require.Equal(t, before, proxy.snapshotView(viewOptions{tabDetails: true}), "remote action must not mutate local proxy metadata") | |||
| 135 | + | }) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) { | |||
| 140 | + | t.Run("router action bytes", func(t *testing.T) { | |||
| 141 | + | _, _, _, link, handler := newProxyInputHarness(t) | |||
| 142 | + | bindings := keys.DefaultBindings() | |||
| 143 | + | var published atomic.Pointer[keys.Bindings] | |||
| 144 | + | published.Store(bindings) | |||
| 145 | + | router := keys.NewRouter(stubClock{}, handler, &published) | |||
| 146 | + | ||||
| 147 | + | router.Route([]byte{keys.ESC, 'j'}) | |||
| 148 | + | requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'}) | |||
| 149 | + | }) | |||
| 150 | + | ||||
| 151 | + | t.Run("retained escape reacquires its original role", func(t *testing.T) { | |||
| 152 | + | d, proxy, ac, link, initial := newProxyInputHarness(t) | |||
| 153 | + | base := initial.roleToken | |||
| 154 | + | base.effect.End() | |||
| 155 | + | base.effect = nil | |||
| 156 | + | ||||
| 157 | + | firstEffect, admitted := ac.beginRoleEffect(base) | |||
| 158 | + | require.True(t, admitted) | |||
| 159 | + | first := base | |||
| 160 | + | first.effect = firstEffect | |||
| 161 | + | ac.keys.RouteWithHandler([]byte{keys.ESC}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: first}) | |||
| 162 | + | firstEffect.End() | |||
| 163 | + | ||||
| 164 | + | secondEffect, admitted := ac.beginRoleEffect(base) | |||
| 165 | + | require.True(t, admitted) | |||
| 166 | + | second := base | |||
| 167 | + | second.effect = secondEffect | |||
| 168 | + | ac.keys.RouteWithHandler([]byte{'j'}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: second}) | |||
| 169 | + | secondEffect.End() | |||
| 170 | + | ||||
| 171 | + | requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'}) | |||
| 172 | + | }) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestProxyInputLocalPaletteNeverForwards(t *testing.T) { | |||
| 176 | + | d, _, ac, link, handler := newProxyInputHarness(t) | |||
| 177 | + | token := handler.roleToken | |||
| 178 | + | handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '}) | |||
| 179 | + | require.True(t, ac.overlays.paletteActive()) | |||
| 180 | + | requireNoProxyFrame(t, link) | |||
| 181 | + | ||||
| 182 | + | // Keyboard and mouse navigation are consumed by the local overlay before | |||
| 183 | + | // either the proxy router or proxy mouse sender can observe them. | |||
| 184 | + | d.handleInputForRole(token, []byte("x")) | |||
| 185 | + | d.handleInputForRole(token, []byte("\x1b[<0;12;8M")) | |||
| 186 | + | requireNoProxyFrame(t, link) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestProxyInputAttentionOwnership(t *testing.T) { | |||
| 190 | + | t.Run("pending remote tab attention forwards original chord", func(t *testing.T) { | |||
| 191 | + | _, proxy, _, link, handler := newProxyInputHarness(t) | |||
| 192 | + | proxy.mu.Lock() | |||
| 193 | + | proxy.meta.Tabs[1].Attention = true | |||
| 194 | + | proxy.mu.Unlock() | |||
| 195 | + | raw := []byte{keys.ESC, 'a'} | |||
| 196 | + | handler.Action(keys.ActionJumpAttention, raw) | |||
| 197 | + | requireProxyInputFrame(t, link, 1, raw) | |||
| 198 | + | }) | |||
| 199 | + | ||||
| 200 | + | t.Run("without remote attention changes selected session locally", func(t *testing.T) { | |||
| 201 | + | d, _, ac, link, handler := newProxyInputHarness(t) | |||
| 202 | + | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 203 | + | tb.attention = true | |||
| 204 | + | tb.attentionAt = time.Unix(1, 0) | |||
| 205 | + | local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}} | |||
| 206 | + | publishTiledPaneOwners(local, tb) | |||
| 207 | + | d.mu.Lock() | |||
| 208 | + | require.True(t, d.registerSessionLocked(local)) | |||
| 209 | + | d.mu.Unlock() | |||
| 210 | + | ||||
| 211 | + | handler.Action(keys.ActionJumpAttention, []byte{keys.ESC, 'a'}) | |||
| 212 | + | ||||
| 213 | + | require.Same(t, local, ac.currentSession()) | |||
| 214 | + | requireNoProxyFrame(t, link) | |||
| 215 | + | }) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func TestProxyInputProxiedRemoteOwnershipGuards(t *testing.T) { | |||
| 219 | + | _, proxy, _, link, _ := newProxyInputHarness(t) | |||
| 220 | + | require.True(t, proxy.caps.cannotAcceptMoves, "a proxy must reject move destinations locally") | |||
| 221 | + | require.True(t, proxy.caps.cannotYieldMoves, "a proxy must reject move sources locally") | |||
| 222 | + | requireNoProxyFrame(t, link) | |||
| 223 | + | ||||
| 224 | + | cfg := domain.NavConfig{OverflowTabs: true, OverflowSessions: true} | |||
| 225 | + | require.Equal(t, domain.NavConfig{OverflowTabs: true}, proxiedNavConfig(cfg), "focus overflow must not cross remote-daemon sessions") | |||
| 226 | + | require.False(t, proxiedJumpSearchesOtherSessions(true), "proxied jump attention must stay in its attached session") | |||
| 227 | + | require.True(t, proxiedJumpSearchesOtherSessions(false)) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func TestProxyPaletteCommandOwnershipIsComplete(t *testing.T) { | |||
| 231 | + | expected := map[string]proxyPaletteOwnership{ | |||
| 232 | + | "new-tab": proxyPaletteRemote, "new-session": proxyPaletteLocal, "close-tab": proxyPaletteRemote, | |||
| 233 | + | "split-right": proxyPaletteRemote, "split-left": proxyPaletteRemote, "split-up": proxyPaletteRemote, "split-down": proxyPaletteRemote, | |||
| 234 | + | "consume-or-expel-pane-left": proxyPaletteRemote, "consume-or-expel-pane-right": proxyPaletteRemote, | |||
| 235 | + | "stack-pane": proxyPaletteRemote, "toggle-stack": proxyPaletteRemote, "toggle-floating-pane": proxyPaletteRemote, "close-pane": proxyPaletteRemote, | |||
| 236 | + | "move-pane": proxyPaletteRejected, "move-tab": proxyPaletteRejected, | |||
| 237 | + | "focus-pane-left": proxyPaletteRemote, "focus-pane-right": proxyPaletteRemote, "focus-pane-up": proxyPaletteRemote, "focus-pane-down": proxyPaletteRemote, | |||
| 238 | + | "resize-pane": proxyPaletteRemote, "grow-pane-width": proxyPaletteRemote, "shrink-pane-width": proxyPaletteRemote, | |||
| 239 | + | "grow-pane-height": proxyPaletteRemote, "shrink-pane-height": proxyPaletteRemote, "equalize-panes": proxyPaletteRemote, | |||
| 240 | + | "next-tab": proxyPaletteRemote, "previous-tab": proxyPaletteRemote, | |||
| 241 | + | "back-session": proxyPaletteLocal, "jump-recent-session": proxyPaletteLocal, "session-picker": proxyPaletteLocal, | |||
| 242 | + | "notifications": proxyPaletteLocal, "yank-last-notification": proxyPaletteLocal, | |||
| 243 | + | "visual-mode": proxyPaletteRejected, "rename-session": proxyPaletteRejected, "rename-tab": proxyPaletteRemote, | |||
| 244 | + | "detach": proxyPaletteLocal, | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | commands := command.PaletteRegistry() | |||
| 248 | + | require.Len(t, expected, len(commands), "ownership table must change whenever the palette registry changes") | |||
| 249 | + | for _, cmd := range commands { | |||
| 250 | + | want, covered := expected[cmd.Slug] | |||
| 251 | + | require.True(t, covered, "palette-visible command %q has no ownership assertion", cmd.Slug) | |||
| 252 | + | require.Equal(t, want, proxyPaletteCommandOwnership(cmd.Slug), cmd.Slug) | |||
| 253 | + | require.Equal(t, want == proxyPaletteRemote, proxyAttachedCommandOwnedRemotely(cmd.Slug), cmd.Slug) | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) { | |||
| 258 | + | t.Run("new session opens local transition prompt", func(t *testing.T) { | |||
| 259 | + | d, proxy, ac, link, handler := newProxyInputHarness(t) | |||
| 260 | + | handler.enterPalette() | |||
| 261 | + | d.handlePaletteInput(ac, []byte("CNS\r"), handler.roleToken.effect) | |||
| 262 | + | ||||
| 263 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 264 | + | require.True(t, ac.overlays.promptActive()) | |||
| 265 | + | require.Same(t, proxy, ac.currentAttachmentSession()) | |||
| 266 | + | requireNoProxyFrame(t, link) | |||
| 267 | + | }) | |||
| 268 | + | ||||
| 269 | + | t.Run("notification yank uses local notice center and clipboard", func(t *testing.T) { | |||
| 270 | + | d, proxy, ac, link, handler := newProxyInputHarness(t) | |||
| 271 | + | d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "local notice", Time: time.Unix(1, 0)}) | |||
| 272 | + | handler.enterPalette() | |||
| 273 | + | client, ok := ac.transport().(*proxyTestTransport) | |||
| 274 | + | require.True(t, ok) | |||
| 275 | + | for len(client.sent) > 0 { | |||
| 276 | + | <-client.sent | |||
| 277 | + | } | |||
| 278 | + | d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect) | |||
| 279 | + | ||||
| 280 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 281 | + | require.Same(t, proxy, ac.currentAttachmentSession()) | |||
| 282 | + | frame := awaitTestValue(t, client.sent, "local notification yank did not reach the client clipboard") | |||
| 283 | + | require.Equal(t, ports.MsgOutput, frame.Type) | |||
| 284 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |||
| 285 | + | require.NoError(t, err) | |||
| 286 | + | require.Contains(t, string(output.Data), "\x1b]52;") | |||
| 287 | + | requireNoProxyFrame(t, link) | |||
| 288 | + | }) | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | type proxyCommandTestTimer struct { | |||
| 292 | + | ch chan time.Time | |||
| 293 | + | once sync.Once | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func newProxyCommandTestTimer() *proxyCommandTestTimer { | |||
| 297 | + | return &proxyCommandTestTimer{ch: make(chan time.Time, 1)} | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func (t *proxyCommandTestTimer) C() <-chan time.Time { return t.ch } | |||
| 301 | + | func (t *proxyCommandTestTimer) Reset(time.Duration) bool { return false } | |||
| 302 | + | func (t *proxyCommandTestTimer) Stop() bool { return true } | |||
| 303 | + | func (t *proxyCommandTestTimer) Fire() { | |||
| 304 | + | t.once.Do(func() { t.ch <- time.Time{} }) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | type proxyCommandTestClock struct{ timers chan *proxyCommandTestTimer } | |||
| 308 | + | ||||
| 309 | + | func newProxyCommandTestClock() *proxyCommandTestClock { | |||
| 310 | + | return &proxyCommandTestClock{timers: make(chan *proxyCommandTestTimer, 8)} | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func (*proxyCommandTestClock) Now() time.Time { return time.Time{} } | |||
| 314 | + | func (c *proxyCommandTestClock) NewTimer(delay time.Duration) ports.Timer { | |||
| 315 | + | if delay != proxyAttachedCommandTimeout { | |||
| 316 | + | return stubTimer{} | |||
| 317 | + | } | |||
| 318 | + | timer := newProxyCommandTestTimer() | |||
| 319 | + | c.timers <- timer | |||
| 320 | + | return timer | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | func requireProxyCommandRequest(t *testing.T, link *proxyTestTransport) ports.CommandRequest { | |||
| 324 | + | t.Helper() | |||
| 325 | + | frame := awaitTestValue(t, link.sent, "proxy command was not sent") | |||
| 326 | + | require.Equal(t, ports.MsgCommand, frame.Type) | |||
| 327 | + | request, err := ports.UnmarshalCommandRequest(frame.Payload) | |||
| 328 | + | require.NoError(t, err) | |||
| 329 | + | return request | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func commandResultFrame(result ports.CommandResult) ports.Frame { | |||
| 333 | + | return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)} | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | func TestProxyAttachedCommandCorrelatesInterleavedWrongAndLateResults(t *testing.T) { | |||
| 337 | + | d, proxy, _, link, _ := newProxyInputHarness(t) | |||
| 338 | + | clock := newProxyCommandTestClock() | |||
| 339 | + | ||||
| 340 | + | firstDone := make(chan ports.CommandResult, 1) | |||
| 341 | + | firstErr := make(chan error, 1) | |||
| 342 | + | go func() { | |||
| 343 | + | result, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil) | |||
| 344 | + | firstDone <- result | |||
| 345 | + | firstErr <- err | |||
| 346 | + | }() | |||
| 347 | + | first := requireProxyCommandRequest(t, link) | |||
| 348 | + | require.Equal(t, uint64(1), first.RequestID) | |||
| 349 | + | require.True(t, first.Attached) | |||
| 350 | + | _ = awaitTestValue(t, clock.timers, "first command did not arm timeout") | |||
| 351 | + | ||||
| 352 | + | generation := proxy.linkGeneration | |||
| 353 | + | result, err := d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})}) | |||
| 354 | + | require.NoError(t, err) | |||
| 355 | + | require.Equal(t, proxyLinkResume, result) | |||
| 356 | + | require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type) | |||
| 357 | + | result, err = d.handleLinkFrame(proxy, generation, proxyMeta(proxy.key.Name)) | |||
| 358 | + | require.NoError(t, err) | |||
| 359 | + | require.Equal(t, proxyLinkResume, result) | |||
| 360 | + | result, err = d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}) | |||
| 361 | + | require.NoError(t, err) | |||
| 362 | + | require.Equal(t, proxyLinkResume, result) | |||
| 363 | + | result, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: 999, OK: true})) | |||
| 364 | + | require.NoError(t, err) | |||
| 365 | + | require.Equal(t, proxyLinkResume, result) | |||
| 366 | + | select { | |||
| 367 | + | case <-firstDone: | |||
| 368 | + | t.Fatal("wrong request ID completed command") | |||
| 369 | + | default: | |||
| 370 | + | } | |||
| 371 | + | require.NoError(t, proxy.sendInput([]byte("x"))) | |||
| 372 | + | requireProxyInputFrame(t, link, 1, []byte("x")) | |||
| 373 | + | ||||
| 374 | + | _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true})) | |||
| 375 | + | require.NoError(t, err) | |||
| 376 | + | require.True(t, (<-firstDone).OK) | |||
| 377 | + | require.NoError(t, <-firstErr) | |||
| 378 | + | ||||
| 379 | + | secondDone := make(chan ports.CommandResult, 1) | |||
| 380 | + | go func() { | |||
| 381 | + | result, _ := proxy.sendCommand(context.Background(), clock, "previous-tab", nil) | |||
| 382 | + | secondDone <- result | |||
| 383 | + | }() | |||
| 384 | + | second := requireProxyCommandRequest(t, link) | |||
| 385 | + | require.Equal(t, uint64(2), second.RequestID) | |||
| 386 | + | _ = awaitTestValue(t, clock.timers, "second command did not arm timeout") | |||
| 387 | + | _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true})) | |||
| 388 | + | require.NoError(t, err) | |||
| 389 | + | select { | |||
| 390 | + | case <-secondDone: | |||
| 391 | + | t.Fatal("late request ID completed replacement command") | |||
| 392 | + | default: | |||
| 393 | + | } | |||
| 394 | + | _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: second.RequestID, Code: ports.ErrInternal, Text: "remote failed"})) | |||
| 395 | + | require.NoError(t, err) | |||
| 396 | + | remoteFailure := <-secondDone | |||
| 397 | + | require.False(t, remoteFailure.OK) | |||
| 398 | + | require.Equal(t, "remote failed", remoteFailure.Text) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | func TestProxyAttachedCommandSharesSerializedSender(t *testing.T) { | |||
| 402 | + | d, proxy, _, link, _ := newProxyInputHarness(t) | |||
| 403 | + | clock := newProxyCommandTestClock() | |||
| 404 | + | gate := make(chan struct{}) | |||
| 405 | + | entered := make(chan struct{}, 2) | |||
| 406 | + | link.sendGate = gate | |||
| 407 | + | link.sendEntered = entered | |||
| 408 | + | ||||
| 409 | + | commandDone := make(chan error, 1) | |||
| 410 | + | go func() { | |||
| 411 | + | _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil) | |||
| 412 | + | commandDone <- err | |||
| 413 | + | }() | |||
| 414 | + | awaitTestCompletion(t, entered, "command sender did not enter transport") | |||
| 415 | + | inputDone := make(chan error, 1) | |||
| 416 | + | go func() { inputDone <- proxy.sendInput([]byte("x")) }() | |||
| 417 | + | select { | |||
| 418 | + | case <-entered: | |||
| 419 | + | t.Fatal("input entered Transport.Send while command send held the shared sender") | |||
| 420 | + | default: | |||
| 421 | + | } | |||
| 422 | + | close(gate) | |||
| 423 | + | request := requireProxyCommandRequest(t, link) | |||
| 424 | + | require.NoError(t, awaitTestValue(t, inputDone, "input send did not finish")) | |||
| 425 | + | requireProxyInputFrame(t, link, 1, []byte("x")) | |||
| 426 | + | _ = awaitTestValue(t, clock.timers, "command did not arm timeout") | |||
| 427 | + | _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true})) | |||
| 428 | + | require.NoError(t, err) | |||
| 429 | + | require.NoError(t, awaitTestValue(t, commandDone, "command did not finish")) | |||
| 430 | + | require.False(t, link.concurrent.Load(), "transport observed concurrent Send calls") | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | func TestProxyAttachedCommandAllowsOnlyOneOutstandingInteractiveRequest(t *testing.T) { | |||
| 434 | + | d, proxy, _, link, _ := newProxyInputHarness(t) | |||
| 435 | + | clock := newProxyCommandTestClock() | |||
| 436 | + | firstDone := make(chan struct{}) | |||
| 437 | + | go func() { | |||
| 438 | + | _, _ = proxy.sendCommand(context.Background(), clock, "next-tab", nil) | |||
| 439 | + | close(firstDone) | |||
| 440 | + | }() | |||
| 441 | + | first := requireProxyCommandRequest(t, link) | |||
| 442 | + | _ = awaitTestValue(t, clock.timers, "first command did not arm timeout") | |||
| 443 | + | ||||
| 444 | + | secondDone := make(chan struct{}) | |||
| 445 | + | go func() { | |||
| 446 | + | _, _ = proxy.sendCommand(context.Background(), clock, "previous-tab", nil) | |||
| 447 | + | close(secondDone) | |||
| 448 | + | }() | |||
| 449 | + | requireNoProxyFrame(t, link) | |||
| 450 | + | _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true})) | |||
| 451 | + | require.NoError(t, err) | |||
| 452 | + | awaitTestCompletion(t, firstDone, "first command did not complete") | |||
| 453 | + | second := requireProxyCommandRequest(t, link) | |||
| 454 | + | _ = awaitTestValue(t, clock.timers, "second command did not arm timeout") | |||
| 455 | + | _, err = d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: second.RequestID, OK: true})) | |||
| 456 | + | require.NoError(t, err) | |||
| 457 | + | awaitTestCompletion(t, secondDone, "second command did not complete") | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T) { | |||
| 461 | + | t.Run("timeout", func(t *testing.T) { | |||
| 462 | + | _, proxy, _, link, _ := newProxyInputHarness(t) | |||
| 463 | + | clock := newProxyCommandTestClock() | |||
| 464 | + | done := make(chan error, 1) | |||
| 465 | + | go func() { | |||
| 466 | + | _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil) | |||
| 467 | + | done <- err | |||
| 468 | + | }() | |||
| 469 | + | _ = requireProxyCommandRequest(t, link) | |||
| 470 | + | timer := awaitTestValue(t, clock.timers, "command did not arm timeout") | |||
| 471 | + | timer.Fire() | |||
| 472 | + | require.ErrorIs(t, awaitTestValue(t, done, "command did not time out"), errProxyCommandTimeout) | |||
| 473 | + | }) | |||
| 474 | + | ||||
| 475 | + | t.Run("context cancellation", func(t *testing.T) { | |||
| 476 | + | _, proxy, _, link, _ := newProxyInputHarness(t) | |||
| 477 | + | clock := newProxyCommandTestClock() | |||
| 478 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 479 | + | done := make(chan error, 1) | |||
| 480 | + | go func() { | |||
| 481 | + | _, err := proxy.sendCommand(ctx, clock, "next-tab", nil) | |||
| 482 | + | done <- err | |||
| 483 | + | }() | |||
| 484 | + | _ = requireProxyCommandRequest(t, link) | |||
| 485 | + | _ = awaitTestValue(t, clock.timers, "command did not arm timeout") | |||
| 486 | + | cancel() | |||
| 487 | + | require.ErrorIs(t, awaitTestValue(t, done, "command did not cancel"), context.Canceled) | |||
| 488 | + | }) | |||
| 489 | + | ||||
| 490 | + | t.Run("link generation replacement", func(t *testing.T) { | |||
| 491 | + | d, proxy, _, link, _ := newProxyInputHarness(t) | |||
| 492 | + | clock := newProxyCommandTestClock() | |||
| 493 | + | done := make(chan error, 1) | |||
| 494 | + | go func() { | |||
| 495 | + | _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil) | |||
| 496 | + | done <- err | |||
| 497 | + | }() | |||
| 498 | + | request := requireProxyCommandRequest(t, link) | |||
| 499 | + | _ = awaitTestValue(t, clock.timers, "command did not arm timeout") | |||
| 500 | + | oldGeneration := proxy.linkGeneration | |||
| 501 | + | newGeneration, _ := proxy.installTransport(newProxyTestTransport()) | |||
| 502 | + | require.Greater(t, newGeneration, oldGeneration) | |||
| 503 | + | require.Error(t, awaitTestValue(t, done, "replacement did not cancel command")) | |||
| 504 | + | _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true})) | |||
| 505 | + | require.NoError(t, err, "late old-generation result must be harmless") | |||
| 506 | + | }) | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *testing.T) { | |||
| 510 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 511 | + | ac.proxied = true | |||
| 512 | + | second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 513 | + | publishTiledPaneOwners(sess, second) | |||
| 514 | + | sess.mu.Lock() | |||
| 515 | + | sess.tabs = append(sess.tabs, second) | |||
| 516 | + | sess.active = 0 | |||
| 517 | + | sess.mu.Unlock() | |||
| 518 | + | d.attachCoordinator(sess, nil, ac, true) | |||
| 519 | + | ||||
| 520 | + | send := func(request ports.CommandRequest) ports.CommandResult { | |||
| 521 | + | payload, err := ports.MarshalCommandRequest(request) | |||
| 522 | + | require.NoError(t, err) | |||
| 523 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 524 | + | require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | |||
| 525 | + | frame := awaitFrame(t, sends, ports.MsgCommandResult) | |||
| 526 | + | result, err := ports.UnmarshalCommandResult(frame.Payload) | |||
| 527 | + | require.NoError(t, err) | |||
| 528 | + | return result | |||
| 529 | + | } | |||
| 530 | + | ||||
| 531 | + | result := send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 1, Attached: true, Slug: "next-tab"}) | |||
| 532 | + | require.True(t, result.OK) | |||
| 533 | + | sess.mu.Lock() | |||
| 534 | + | require.Equal(t, 1, sess.active) | |||
| 535 | + | sess.mu.Unlock() | |||
| 536 | + | ||||
| 537 | + | result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 2, Attached: true, Slug: "previous-tab", TargetSession: "other"}) | |||
| 538 | + | require.False(t, result.OK) | |||
| 539 | + | sess.mu.Lock() | |||
| 540 | + | require.Equal(t, 1, sess.active, "target override must not execute against any session") | |||
| 541 | + | sess.mu.Unlock() | |||
| 542 | + | ||||
| 543 | + | result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 3, Attached: true, Slug: "rename-tab"}) | |||
| 544 | + | require.True(t, result.OK) | |||
| 545 | + | require.True(t, ac.overlays.promptActive(), "attached interactive command must be able to open the remote prompt") | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | func TestProxyAttachedCommandRemoteErrorCreatesLocalNotice(t *testing.T) { | |||
| 549 | + | d, proxy, ac, link, handler := newProxyInputHarness(t) | |||
| 550 | + | clock := newProxyCommandTestClock() | |||
| 551 | + | d.clock = clock | |||
| 552 | + | handler.enterPalette() | |||
| 553 | + | done := make(chan struct{}) | |||
| 554 | + | go func() { | |||
| 555 | + | d.handlePaletteInput(ac, []byte("NXT\r"), handler.roleToken.effect) | |||
| 556 | + | close(done) | |||
| 557 | + | }() | |||
| 558 | + | request := requireProxyCommandRequest(t, link) | |||
| 559 | + | _ = awaitTestValue(t, clock.timers, "palette command did not arm timeout") | |||
| 560 | + | _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, Code: ports.ErrInternal, Text: "remote failed"})) | |||
| 561 | + | require.NoError(t, err) | |||
| 562 | + | awaitTestCompletion(t, done, "palette command did not finish") | |||
| 563 | + | notice, ok := d.notices.latest() | |||
| 564 | + | require.True(t, ok) | |||
| 565 | + | require.Equal(t, domain.NoticeSessionUnavailable, notice.Code) | |||
| 566 | + | require.Contains(t, notice.Message, "remote failed") | |||
| 567 | + | } | |||
| 568 | + | ||||
| 569 | + | func TestProxyAttachedCommandMalformedAndUnavailableResultsAreSafe(t *testing.T) { | |||
| 570 | + | d, proxy, _, _, _ := newProxyInputHarness(t) | |||
| 571 | + | result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{Type: ports.MsgCommandResult, Payload: []byte{1}}) | |||
| 572 | + | require.Equal(t, proxyLinkStop, result) | |||
| 573 | + | require.Error(t, err) | |||
| 574 | + | ||||
| 575 | + | proxy.mu.Lock() | |||
| 576 | + | proxy.transport = nil | |||
| 577 | + | proxy.mu.Unlock() | |||
| 578 | + | _, err = proxy.sendCommand(context.Background(), stubClock{}, "next-tab", nil) | |||
| 579 | + | require.True(t, errors.Is(err, errProxyCommandUnavailable)) | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | func TestProxyOwnershipMakesCompatibleRemoteRowsSelectable(t *testing.T) { | |||
| 583 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 584 | + | for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} { | |||
| 585 | + | view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running", Tabs: 2}, status, time.Unix(1, 0)) | |||
| 586 | + | require.True(t, view.RemoteAttachReady, "compatible status %d must be enabled after ownership is complete", status) | |||
| 587 | + | model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |||
| 588 | + | target, selectable := model.Selected() | |||
| 589 | + | require.True(t, selectable) | |||
| 590 | + | require.Equal(t, key, *target.RemoteKey) | |||
| 591 | + | } | |||
| 592 | + | mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{}) | |||
| 593 | + | require.False(t, mismatch.RemoteAttachReady) | |||
| 594 | + | _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() | |||
| 595 | + | require.False(t, selectable) | |||
| 596 | + | } | |||
| 597 | + | ||||
| 598 | + | func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *testing.T) { | |||
| 599 | + | d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 600 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 601 | + | transport := newProxyTestTransport() | |||
| 602 | + | transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 603 | + | transport.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 604 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |||
| 605 | + | dialer := portsmocks.NewMockDialer(t) | |||
| 606 | + | var daemonLockAvailable, sourceLockAvailable bool | |||
| 607 | + | factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything). | |||
| 608 | + | Run(func(string, string, ports.RemoteTransportMode, *slog.Logger) { | |||
| 609 | + | // If picker selection retained either architecture lock while dialing, | |||
| 610 | + | // this synchronous callback would deadlock. | |||
| 611 | + | d.mu.Lock() | |||
| 612 | + | daemonLockAvailable = true | |||
| 613 | + | d.mu.Unlock() | |||
| 614 | + | source.mu.Lock() | |||
| 615 | + | sourceLockAvailable = true | |||
| 616 | + | source.mu.Unlock() | |||
| 617 | + | }).Return(dialer, nil).Once() | |||
| 618 | + | dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once() | |||
| 619 | + | d.remoteDialerFactory = factory | |||
| 620 | + | d.remoteTransportMode = ports.RemoteTransportUDP | |||
| 621 | + | d.attachCoordinator(source, nil, ac, true) | |||
| 622 | + | ||||
| 623 | + | token := source.attachmentToken(ac, ac.transport()) | |||
| 624 | + | effect, admitted := ac.beginRoleEffect(token) | |||
| 625 | + | require.True(t, admitted) | |||
| 626 | + | token.effect = effect | |||
| 627 | + | err := d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |||
| 628 | + | require.NoError(t, err) | |||
| 629 | + | require.True(t, daemonLockAvailable) | |||
| 630 | + | require.True(t, sourceLockAvailable) | |||
| 631 | + | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |||
| 632 | + | require.True(t, ok) | |||
| 633 | + | require.Equal(t, key, proxy.key) | |||
| 634 | + | t.Cleanup(func() { stopProxy(t, proxy) }) | |||
| 635 | + | ||||
| 636 | + | wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"} | |||
| 637 | + | currentToken := attachmentToken(proxy, ac, ac.transport()) | |||
| 638 | + | currentEffect, admitted := ac.beginRoleEffect(currentToken) | |||
| 639 | + | require.True(t, admitted) | |||
| 640 | + | currentToken.effect = currentEffect | |||
| 641 | + | err = d.switchToTargetForRole(currentToken, picker.Target{Session: key.ID(), RemoteKey: &wrong, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |||
| 642 | + | require.ErrorIs(t, err, errAttachmentTransition) | |||
| 643 | + | require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment") | |||
| 644 | + | ||||
| 645 | + | staleToken := attachmentToken(proxy, ac, ac.transport()) | |||
| 646 | + | staleEffect, admitted := ac.beginRoleEffect(staleToken) | |||
| 647 | + | require.True(t, admitted) | |||
| 648 | + | staleToken.effect = staleEffect | |||
| 649 | + | ac.roleGeneration.Add(1) // model a concurrent role replacement after selection | |||
| 650 | + | err = d.switchToTargetForRole(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |||
| 651 | + | require.ErrorIs(t, err, errAttachmentTransition) | |||
| 652 | + | require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment") | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) { | |||
| 656 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 657 | + | d.attachCoordinator(local, nil, ac, true) | |||
| 658 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "enter"} | |||
| 659 | + | transport := newProxyTestTransport() | |||
| 660 | + | transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 661 | + | transport.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 662 | + | d.remoteDialerFactory = newProxyConstructionFactory(transport) | |||
| 663 | + | d.remoteTransportMode = ports.RemoteTransportUDP | |||
| 664 | + | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |||
| 665 | + | Host: key.Host, FetchedAt: time.Unix(1, 0), | |||
| 666 | + | Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}}, | |||
| 667 | + | }}) | |||
| 668 | + | ||||
| 669 | + | model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key}) | |||
| 670 | + | d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{}) | |||
| 671 | + | target, selectable := model.Selected() | |||
| 672 | + | require.True(t, selectable) | |||
| 673 | + | require.Equal(t, key, *target.RemoteKey) | |||
| 674 | + | ||||
| 675 | + | token := local.attachmentToken(ac, ac.transport()) | |||
| 676 | + | effect, admitted := ac.beginRoleEffect(token) | |||
| 677 | + | require.True(t, admitted) | |||
| 678 | + | d.handlePickerInput(ac, []byte("\r"), effect) | |||
| 679 | + | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |||
| 680 | + | require.True(t, ok, "Enter must route the row instead of only marking it selectable") | |||
| 681 | + | require.Equal(t, key, proxy.key) | |||
| 682 | + | t.Cleanup(func() { stopProxy(t, proxy) }) | |||
| 683 | + | } | |||
A internal/usecase/daemon/proxy_kill.go +276 -0
| @@ -0,0 +1,276 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const proxyKillTimeout = 10 * time.Second | |||
| 15 | + | ||||
| 16 | + | var ( | |||
| 17 | + | errProxyKillTimeout = errors.New("proxy session: remote kill timed out") | |||
| 18 | + | errProxyKillGeneration = errors.New("proxy session: remote kill lifecycle changed") | |||
| 19 | + | errProxyKillConfirmation = errors.New("type the full remote session label exactly") | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | type proxyKillIOResult struct { | |||
| 23 | + | frame ports.Frame | |||
| 24 | + | err error | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // enterRemoteKillConfirmation turns a remote picker deletion into an exact, | |||
| 28 | + | // typed lifecycle capability. The proxy pointer is resolved when the prompt is | |||
| 29 | + | // opened, while its generation is deliberately captured only after the exact | |||
| 30 | + | // label is submitted. | |||
| 31 | + | func (d *Daemon) enterRemoteKillConfirmation(owner attachmentSession, ac *attachedClient, target picker.Target) bool { | |||
| 32 | + | if d == nil || owner == nil || ac == nil || target.RemoteKey == nil || target.Session != target.RemoteKey.ID() { | |||
| 33 | + | return false | |||
| 34 | + | } | |||
| 35 | + | key := *target.RemoteKey | |||
| 36 | + | d.mu.Lock() | |||
| 37 | + | proxy, ok := d.sessions[target.Session].(*proxySession) | |||
| 38 | + | ok = ok && proxy.key == key | |||
| 39 | + | d.mu.Unlock() | |||
| 40 | + | if !ok { | |||
| 41 | + | return false | |||
| 42 | + | } | |||
| 43 | + | title := " Type " + key.Display() + " to kill " | |||
| 44 | + | proxy.mu.Lock() | |||
| 45 | + | unreachable := proxy.expired || proxy.linkState == ports.LinkStateOffline || proxy.linkState == ports.LinkStateDead | |||
| 46 | + | proxy.mu.Unlock() | |||
| 47 | + | if unreachable { | |||
| 48 | + | title = " Type " + key.Display() + " to kill (remote unreachable) " | |||
| 49 | + | } | |||
| 50 | + | d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentRoleToken) error { | |||
| 51 | + | if value != key.Display() { | |||
| 52 | + | return errProxyKillConfirmation | |||
| 53 | + | } | |||
| 54 | + | if token.ac != nil { | |||
| 55 | + | if !token.activeCurrent() { | |||
| 56 | + | return errAttachmentTransition | |||
| 57 | + | } | |||
| 58 | + | token.effect.bindActionEnd(d, "remote-picker-delete") | |||
| 59 | + | token.effect.End() | |||
| 60 | + | } | |||
| 61 | + | d.mu.Lock() | |||
| 62 | + | if d.sessions[proxy.id] != proxy { | |||
| 63 | + | d.mu.Unlock() | |||
| 64 | + | return errProxyKillGeneration | |||
| 65 | + | } | |||
| 66 | + | proxy.mu.Lock() | |||
| 67 | + | generation := proxy.generation | |||
| 68 | + | proxy.mu.Unlock() | |||
| 69 | + | d.mu.Unlock() | |||
| 70 | + | ctx := d.serveCtx | |||
| 71 | + | if ctx == nil { | |||
| 72 | + | ctx = context.Background() | |||
| 73 | + | } | |||
| 74 | + | return d.killRemoteProxy(ctx, proxy, generation) | |||
| 75 | + | }) | |||
| 76 | + | return true | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // killRemoteProxy uses a fresh control transport and accepts only EOF as the | |||
| 80 | + | // remote daemon's authoritative kill acknowledgement. No live or cached state | |||
| 81 | + | // is changed before that acknowledgement and exact generation publication. | |||
| 82 | + | func (d *Daemon) killRemoteProxy(ctx context.Context, proxy *proxySession, generation uint64) error { | |||
| 83 | + | if d == nil || proxy == nil || d.remoteDialerFactory == nil || d.clock == nil { | |||
| 84 | + | return errors.New("proxy session: remote kill is unavailable") | |||
| 85 | + | } | |||
| 86 | + | if ctx == nil { | |||
| 87 | + | ctx = context.Background() | |||
| 88 | + | } | |||
| 89 | + | if err := ctx.Err(); err != nil { | |||
| 90 | + | return err | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | d.mu.Lock() | |||
| 94 | + | current := d.sessions[proxy.id] == proxy | |||
| 95 | + | if current { | |||
| 96 | + | proxy.mu.Lock() | |||
| 97 | + | current = proxy.generation == generation | |||
| 98 | + | proxy.mu.Unlock() | |||
| 99 | + | } | |||
| 100 | + | d.mu.Unlock() | |||
| 101 | + | if !current { | |||
| 102 | + | return errProxyKillGeneration | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | timer := d.clock.NewTimer(proxyKillTimeout) | |||
| 106 | + | if timer == nil { | |||
| 107 | + | return errProxyKillTimeout | |||
| 108 | + | } | |||
| 109 | + | defer timer.Stop() | |||
| 110 | + | boundedCtx, cancel := context.WithCancel(ctx) | |||
| 111 | + | defer cancel() | |||
| 112 | + | timedOut := make(chan struct{}) | |||
| 113 | + | stopTimeout := make(chan struct{}) | |||
| 114 | + | defer close(stopTimeout) | |||
| 115 | + | go func() { | |||
| 116 | + | select { | |||
| 117 | + | case <-timer.C(): | |||
| 118 | + | close(timedOut) | |||
| 119 | + | cancel() | |||
| 120 | + | case <-stopTimeout: | |||
| 121 | + | } | |||
| 122 | + | }() | |||
| 123 | + | ||||
| 124 | + | dialer, err := d.remoteDialerFactory.DialerForRemote(proxy.key.Host, proxy.key.Name, d.remoteTransportMode, d.log) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return fmt.Errorf("proxy session: select remote kill dialer: %w", err) | |||
| 127 | + | } | |||
| 128 | + | if dialer == nil { | |||
| 129 | + | return errors.New("proxy session: remote kill dialer is nil") | |||
| 130 | + | } | |||
| 131 | + | transport, err := dialer.Dial(boundedCtx) | |||
| 132 | + | if err != nil { | |||
| 133 | + | return proxyKillContextError(ctx, timedOut, fmt.Errorf("proxy session: remote kill dial: %w", err)) | |||
| 134 | + | } | |||
| 135 | + | if transport == nil { | |||
| 136 | + | return errors.New("proxy session: remote kill transport is nil") | |||
| 137 | + | } | |||
| 138 | + | defer func() { _ = transport.Close() }() | |||
| 139 | + | ||||
| 140 | + | killFrame := ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: proxy.key.Name})} | |||
| 141 | + | sendDone := make(chan error, 1) | |||
| 142 | + | go func() { sendDone <- transport.Send(killFrame) }() | |||
| 143 | + | select { | |||
| 144 | + | case err := <-sendDone: | |||
| 145 | + | if err != nil { | |||
| 146 | + | return fmt.Errorf("proxy session: send remote kill: %w", err) | |||
| 147 | + | } | |||
| 148 | + | case <-boundedCtx.Done(): | |||
| 149 | + | _ = transport.Close() | |||
| 150 | + | // Transport promises Close unblocks Send, but this boundary cannot let a | |||
| 151 | + | // broken adapter defeat the caller's bound. The buffered result lets the | |||
| 152 | + | // caller return; a non-cooperative adapter may retain this one transport | |||
| 153 | + | // goroutine until Send eventually exits. It owns no daemon locks or state. | |||
| 154 | + | return proxyKillContextError(ctx, timedOut, boundedCtx.Err()) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | recvDone := make(chan proxyKillIOResult, 1) | |||
| 158 | + | go func() { | |||
| 159 | + | frame, err := transport.Recv() | |||
| 160 | + | recvDone <- proxyKillIOResult{frame: frame, err: err} | |||
| 161 | + | }() | |||
| 162 | + | var received proxyKillIOResult | |||
| 163 | + | select { | |||
| 164 | + | case received = <-recvDone: | |||
| 165 | + | case <-boundedCtx.Done(): | |||
| 166 | + | _ = transport.Close() | |||
| 167 | + | // Apply the same bounded boundary defensively to Recv. A conforming | |||
| 168 | + | // transport exits on Close; a broken one may retain this transport-owned | |||
| 169 | + | // goroutine, but cannot delay or mutate the failed kill operation. | |||
| 170 | + | return proxyKillContextError(ctx, timedOut, boundedCtx.Err()) | |||
| 171 | + | } | |||
| 172 | + | if !errors.Is(received.err, io.EOF) { | |||
| 173 | + | if received.err != nil { | |||
| 174 | + | return fmt.Errorf("proxy session: receive remote kill result: %w", received.err) | |||
| 175 | + | } | |||
| 176 | + | if received.frame.Type == ports.MsgError { | |||
| 177 | + | remoteErr, decodeErr := ports.UnmarshalErrorMsg(received.frame.Payload) | |||
| 178 | + | if decodeErr != nil { | |||
| 179 | + | return fmt.Errorf("proxy session: malformed remote kill error: %w", decodeErr) | |||
| 180 | + | } | |||
| 181 | + | return fmt.Errorf("proxy session: remote kill failed (%d): %s", remoteErr.Code, remoteErr.Text) | |||
| 182 | + | } | |||
| 183 | + | return fmt.Errorf("proxy session: unexpected remote kill reply type %d", received.frame.Type) | |||
| 184 | + | } | |||
| 185 | + | return d.publishRemoteProxyKill(proxy, generation) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func proxyKillContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error { | |||
| 189 | + | select { | |||
| 190 | + | case <-timedOut: | |||
| 191 | + | return errProxyKillTimeout | |||
| 192 | + | default: | |||
| 193 | + | } | |||
| 194 | + | if err := parent.Err(); err != nil { | |||
| 195 | + | return err | |||
| 196 | + | } | |||
| 197 | + | return fallback | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // publishRemoteProxyKill atomically removes only the acknowledged proxy | |||
| 201 | + | // generation and matching discovery row. Teardown and persistence are external | |||
| 202 | + | // operations and therefore happen after every architecture lock is released. | |||
| 203 | + | func (d *Daemon) publishRemoteProxyKill(proxy *proxySession, generation uint64) error { | |||
| 204 | + | d.mu.Lock() | |||
| 205 | + | if d.sessions[proxy.id] != proxy { | |||
| 206 | + | d.mu.Unlock() | |||
| 207 | + | return errProxyKillGeneration | |||
| 208 | + | } | |||
| 209 | + | proxy.sessionCore.mu.Lock() | |||
| 210 | + | proxy.mu.Lock() | |||
| 211 | + | if proxy.generation != generation { | |||
| 212 | + | proxy.mu.Unlock() | |||
| 213 | + | proxy.sessionCore.mu.Unlock() | |||
| 214 | + | d.mu.Unlock() | |||
| 215 | + | return errProxyKillGeneration | |||
| 216 | + | } | |||
| 217 | + | proxy.generation++ | |||
| 218 | + | proxy.expired = true | |||
| 219 | + | warm := proxy.warm | |||
| 220 | + | proxy.warm = nil | |||
| 221 | + | cancel := proxy.cancel | |||
| 222 | + | transport := proxy.transport | |||
| 223 | + | coordinator := proxy.coordinator.Load() | |||
| 224 | + | proxy.mu.Unlock() | |||
| 225 | + | if !d.unregisterSessionLocked(proxy) { | |||
| 226 | + | proxy.sessionCore.mu.Unlock() | |||
| 227 | + | d.mu.Unlock() | |||
| 228 | + | return errProxyKillGeneration | |||
| 229 | + | } | |||
| 230 | + | proxy.sessionCore.mu.Unlock() | |||
| 231 | + | ||||
| 232 | + | cacheChanged := false | |||
| 233 | + | d.remoteCatalog.mu.Lock() | |||
| 234 | + | if entry, ok := d.remoteCatalog.cache[proxy.key.Host]; ok { | |||
| 235 | + | sessions := make([]ports.RemoteCatalogSession, 0, len(entry.Sessions)) | |||
| 236 | + | for _, session := range entry.Sessions { | |||
| 237 | + | if session.Name == proxy.key.Name { | |||
| 238 | + | cacheChanged = true | |||
| 239 | + | continue | |||
| 240 | + | } | |||
| 241 | + | sessions = append(sessions, session) | |||
| 242 | + | } | |||
| 243 | + | if cacheChanged { | |||
| 244 | + | entry.Sessions = sessions | |||
| 245 | + | d.remoteCatalog.cache[proxy.key.Host] = entry | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | d.remoteCatalog.mu.Unlock() | |||
| 249 | + | empty := len(d.sessions) == 0 | |||
| 250 | + | if empty { | |||
| 251 | + | d.closing = true | |||
| 252 | + | } | |||
| 253 | + | d.mu.Unlock() | |||
| 254 | + | ||||
| 255 | + | warm.stop() | |||
| 256 | + | if cancel != nil { | |||
| 257 | + | cancel() | |||
| 258 | + | } | |||
| 259 | + | if transport != nil { | |||
| 260 | + | _ = transport.Close() | |||
| 261 | + | } | |||
| 262 | + | if coordinator != nil { | |||
| 263 | + | coordinator.beginSessionTeardown().finish() | |||
| 264 | + | coordinator.waitForTimerWorkers() | |||
| 265 | + | } | |||
| 266 | + | proxy.finish() | |||
| 267 | + | if cacheChanged { | |||
| 268 | + | d.persistRemoteCatalogCache() | |||
| 269 | + | } | |||
| 270 | + | if empty { | |||
| 271 | + | d.doneOnce.Do(func() { close(d.done) }) | |||
| 272 | + | } else { | |||
| 273 | + | d.refreshRemoteOpenPickers() | |||
| 274 | + | } | |||
| 275 | + | return nil | |||
| 276 | + | } | |||
M internal/usecase/daemon/proxy_lifecycle.go +46 -0
| @@ -36,6 +36,52 @@ func (t *proxyWarmTimer) stop() { | |||||
| 36 | t.cancelOnce.Do(func() { close(t.cancel) }) | 36 | t.cancelOnce.Do(func() { close(t.cancel) }) | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | + | // proxyAttachmentTransitionCommitted applies lifecycle policy only after the | |||
| 40 | + | // attachment publication has released every architecture lock. Each helper | |||
| 41 | + | // performs its own exact pointer/client revalidation before changing a timer. | |||
| 42 | + | func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveRole bool) { | |||
| 43 | + | if d == nil || preserveRole || source == target { | |||
| 44 | + | return | |||
| 45 | + | } | |||
| 46 | + | if proxy, ok := source.(*proxySession); ok { | |||
| 47 | + | d.armProxyWarm(proxy) | |||
| 48 | + | } | |||
| 49 | + | if proxy, ok := target.(*proxySession); ok { | |||
| 50 | + | if d.cancelProxyWarmForAttachment(proxy, ac) { | |||
| 51 | + | d.touchMRU(proxy) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // cancelProxyWarmForAttachment marks an exact published attachment as active. | |||
| 57 | + | // Timer operations occur only after d.mu, sessionCore.mu, and proxy.mu have all | |||
| 58 | + | // been released. | |||
| 59 | + | func (d *Daemon) cancelProxyWarmForAttachment(p *proxySession, ac *attachedClient) bool { | |||
| 60 | + | if d == nil || p == nil || ac == nil { | |||
| 61 | + | return false | |||
| 62 | + | } | |||
| 63 | + | d.mu.Lock() | |||
| 64 | + | if d.sessions[p.id] != p { | |||
| 65 | + | d.mu.Unlock() | |||
| 66 | + | return false | |||
| 67 | + | } | |||
| 68 | + | p.sessionCore.mu.Lock() | |||
| 69 | + | if p.client != ac { | |||
| 70 | + | p.sessionCore.mu.Unlock() | |||
| 71 | + | d.mu.Unlock() | |||
| 72 | + | return false | |||
| 73 | + | } | |||
| 74 | + | p.mu.Lock() | |||
| 75 | + | p.generation++ | |||
| 76 | + | old := p.warm | |||
| 77 | + | p.warm = nil | |||
| 78 | + | p.mu.Unlock() | |||
| 79 | + | p.sessionCore.mu.Unlock() | |||
| 80 | + | d.mu.Unlock() | |||
| 81 | + | old.stop() | |||
| 82 | + | return true | |||
| 83 | + | } | |||
| 84 | + | ||||
| 39 | // armProxyWarm reserves and publishes a fresh five-minute timer only while the | 85 | // armProxyWarm reserves and publishes a fresh five-minute timer only while the | ||
| 40 | // exact proxy remains registered and headless. Clock and Timer methods are | 86 | // exact proxy remains registered and headless. Clock and Timer methods are | ||
| 41 | // external operations and therefore run outside architecture locks. | 87 | // external operations and therefore run outside architecture locks. | ||
M internal/usecase/daemon/proxy_lifecycle_test.go +1334 -3
| @@ -1,18 +1,1237 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "log/slog" | |||
| 4 | "sync" | 9 | "sync" | ||
| 5 | "testing" | 10 | "testing" | ||
| 6 | "time" | 11 | "time" | ||
| 7 | 12 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 15 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 10 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 17 | ) | ||
| 12 | 18 | ||||
| 13 | - | // warmHookClock runs a one-shot hook while armProxyWarm sits between its | 19 | + | type proxyLifecycleClock struct { |
| 14 | - | // reservation and its publication, so a lifecycle race can be interleaved | 20 | + | mu sync.RWMutex |
| 15 | - | // deterministically instead of being waited for. | 21 | + | now time.Time |
| 22 | + | timers chan *proxyLifecycleTimer | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func newProxyLifecycleClock() *proxyLifecycleClock { | |||
| 26 | + | return &proxyLifecycleClock{ | |||
| 27 | + | now: time.Unix(1_800_000_000, 0), | |||
| 28 | + | timers: make(chan *proxyLifecycleTimer, 16), | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (c *proxyLifecycleClock) Now() time.Time { | |||
| 33 | + | c.mu.RLock() | |||
| 34 | + | defer c.mu.RUnlock() | |||
| 35 | + | return c.now | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (c *proxyLifecycleClock) Advance(delay time.Duration) { | |||
| 39 | + | c.mu.Lock() | |||
| 40 | + | c.now = c.now.Add(delay) | |||
| 41 | + | c.mu.Unlock() | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (c *proxyLifecycleClock) NewTimer(delay time.Duration) ports.Timer { | |||
| 45 | + | timer := &proxyLifecycleTimer{clock: c, delay: delay, ticks: make(chan time.Time, 1)} | |||
| 46 | + | select { | |||
| 47 | + | case c.timers <- timer: | |||
| 48 | + | default: | |||
| 49 | + | } | |||
| 50 | + | return timer | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | type proxyLifecycleTimer struct { | |||
| 54 | + | mu sync.Mutex | |||
| 55 | + | clock *proxyLifecycleClock | |||
| 56 | + | delay time.Duration | |||
| 57 | + | ticks chan time.Time | |||
| 58 | + | stopped bool | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (t *proxyLifecycleTimer) C() <-chan time.Time { return t.ticks } | |||
| 62 | + | func (t *proxyLifecycleTimer) Reset(delay time.Duration) bool { | |||
| 63 | + | t.mu.Lock() | |||
| 64 | + | wasActive := !t.stopped | |||
| 65 | + | t.delay = delay | |||
| 66 | + | t.stopped = false | |||
| 67 | + | t.mu.Unlock() | |||
| 68 | + | return wasActive | |||
| 69 | + | } | |||
| 70 | + | func (t *proxyLifecycleTimer) Stop() bool { | |||
| 71 | + | t.mu.Lock() | |||
| 72 | + | wasActive := !t.stopped | |||
| 73 | + | t.stopped = true | |||
| 74 | + | t.mu.Unlock() | |||
| 75 | + | return wasActive | |||
| 76 | + | } | |||
| 77 | + | func (t *proxyLifecycleTimer) fire() { | |||
| 78 | + | t.clock.Advance(t.delay) | |||
| 79 | + | select { | |||
| 80 | + | case t.ticks <- t.clock.Now(): | |||
| 81 | + | default: | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestProxyLifecycleClockTimerRegistrationDoesNotBlock(t *testing.T) { | |||
| 86 | + | clock := newProxyLifecycleClock() | |||
| 87 | + | for range cap(clock.timers) { | |||
| 88 | + | clock.NewTimer(time.Minute) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | registered := make(chan struct{}) | |||
| 92 | + | go func() { | |||
| 93 | + | clock.NewTimer(time.Minute) | |||
| 94 | + | close(registered) | |||
| 95 | + | }() | |||
| 96 | + | awaitTestCompletion(t, registered, "timer registration blocked on observer capacity") | |||
| 97 | + | for range cap(clock.timers) { | |||
| 98 | + | _ = awaitTestValue(t, clock.timers, "registered timer was not observable") | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | type proxyLifecycleTransport struct { | |||
| 103 | + | d *Daemon | |||
| 104 | + | proxy *proxySession | |||
| 105 | + | closed chan struct{} | |||
| 106 | + | closeOne sync.Once | |||
| 107 | + | unlocked bool | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (t *proxyLifecycleTransport) Send(ports.Frame) error { return nil } | |||
| 111 | + | func (t *proxyLifecycleTransport) Recv() (ports.Frame, error) { | |||
| 112 | + | <-t.closed | |||
| 113 | + | return ports.Frame{}, nil | |||
| 114 | + | } | |||
| 115 | + | func (t *proxyLifecycleTransport) Close() error { | |||
| 116 | + | t.closeOne.Do(func() { | |||
| 117 | + | daemonUnlocked := t.d.mu.TryLock() | |||
| 118 | + | if daemonUnlocked { | |||
| 119 | + | t.d.mu.Unlock() | |||
| 120 | + | } | |||
| 121 | + | coreUnlocked := t.proxy.sessionCore.mu.TryLock() | |||
| 122 | + | if coreUnlocked { | |||
| 123 | + | t.proxy.sessionCore.mu.Unlock() | |||
| 124 | + | } | |||
| 125 | + | proxyUnlocked := t.proxy.mu.TryLock() | |||
| 126 | + | if proxyUnlocked { | |||
| 127 | + | t.proxy.mu.Unlock() | |||
| 128 | + | } | |||
| 129 | + | t.unlocked = daemonUnlocked && coreUnlocked && proxyUnlocked | |||
| 130 | + | close(t.closed) | |||
| 131 | + | }) | |||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func newRegisteredLifecycleProxy(t *testing.T, clock ports.Clock) (*Daemon, *proxySession, *proxyLifecycleTransport) { | |||
| 136 | + | t.Helper() | |||
| 137 | + | d := newTestDaemon(t, nil, clock) | |||
| 138 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize) | |||
| 139 | + | require.NoError(t, err) | |||
| 140 | + | transport := &proxyLifecycleTransport{d: d, proxy: proxy, closed: make(chan struct{})} | |||
| 141 | + | proxy.transport = transport | |||
| 142 | + | proxy.linkGeneration = 1 | |||
| 143 | + | d.mu.Lock() | |||
| 144 | + | require.True(t, d.registerSessionLocked(proxy)) | |||
| 145 | + | d.mu.Unlock() | |||
| 146 | + | return d, proxy, transport | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) { | |||
| 150 | + | clock := newProxyLifecycleClock() | |||
| 151 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 152 | + | d.clock = clock | |||
| 153 | + | localCoordinator := d.attachCoordinator(local, nil, ac, true) | |||
| 154 | + | localToken := attachmentToken(local, ac, ac.transport()) | |||
| 155 | + | localToken.lease = localCoordinator.attachmentLease(ac) | |||
| 156 | + | ||||
| 157 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ac.size) | |||
| 158 | + | require.NoError(t, err) | |||
| 159 | + | d.mu.Lock() | |||
| 160 | + | require.True(t, d.registerSessionLocked(proxy)) | |||
| 161 | + | d.mu.Unlock() | |||
| 162 | + | ||||
| 163 | + | toProxy, err := d.transitionAttachment(attachmentTransitionRequest{ | |||
| 164 | + | source: local, target: proxy, next: ac, | |||
| 165 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |||
| 166 | + | expectedTransport: ac.transportSnapshot(), sourceToken: &localToken, | |||
| 167 | + | action: "lifecycle test", ready: true, | |||
| 168 | + | }) | |||
| 169 | + | require.NoError(t, err) | |||
| 170 | + | require.NotZero(t, proxy.mruAt.Load(), "proxy activation must participate in unified MRU ordering") | |||
| 171 | + | proxy.mu.Lock() | |||
| 172 | + | require.Nil(t, proxy.warm, "attachment must leave no warm timer published") | |||
| 173 | + | proxy.mu.Unlock() | |||
| 174 | + | ||||
| 175 | + | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |||
| 176 | + | source: proxy, target: local, next: ac, | |||
| 177 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |||
| 178 | + | expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published, | |||
| 179 | + | action: "lifecycle test", ready: true, | |||
| 180 | + | }) | |||
| 181 | + | require.NoError(t, err) | |||
| 182 | + | timer := awaitTestValue(t, clock.timers, "proxy timer was not registered") | |||
| 183 | + | require.Equal(t, proxyWarmDuration, timer.delay) | |||
| 184 | + | proxy.mu.Lock() | |||
| 185 | + | require.NotNil(t, proxy.warm, "switch-away must arm the warm lifecycle") | |||
| 186 | + | proxy.mu.Unlock() | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestProxyWarmLifecycleUsesExactFiveMinuteGeneration(t *testing.T) { | |||
| 190 | + | clock := newProxyLifecycleClock() | |||
| 191 | + | d, proxy, transport := newRegisteredLifecycleProxy(t, clock) | |||
| 192 | + | d.remoteCatalog.mu.Lock() | |||
| 193 | + | d.remoteCatalog.cache[proxy.key.Host] = ports.RemoteCatalogCacheEntry{Host: proxy.key.Host, FetchedAt: clock.Now()} | |||
| 194 | + | d.remoteCatalog.mu.Unlock() | |||
| 195 | + | ||||
| 196 | + | d.armProxyWarm(proxy) | |||
| 197 | + | timer := awaitTestValue(t, clock.timers, "proxy timer was not registered") | |||
| 198 | + | require.Equal(t, 5*time.Minute, timer.delay) | |||
| 199 | + | proxy.mu.Lock() | |||
| 200 | + | token := proxy.warm | |||
| 201 | + | generation := proxy.generation | |||
| 202 | + | proxy.mu.Unlock() | |||
| 203 | + | require.NotNil(t, token) | |||
| 204 | + | ||||
| 205 | + | timer.fire() | |||
| 206 | + | awaitTestCompletion(t, token.done, "proxy warm lifecycle did not complete") | |||
| 207 | + | ||||
| 208 | + | d.mu.Lock() | |||
| 209 | + | require.NotContains(t, d.sessions, proxy.id) | |||
| 210 | + | require.True(t, d.closing, "the last warm proxy expiry must begin daemon shutdown") | |||
| 211 | + | d.mu.Unlock() | |||
| 212 | + | require.True(t, transport.unlocked, "transport close must run after daemon/core/proxy locks are released") | |||
| 213 | + | proxy.mu.Lock() | |||
| 214 | + | require.Greater(t, proxy.generation, generation) | |||
| 215 | + | proxy.mu.Unlock() | |||
| 216 | + | d.remoteCatalog.mu.Lock() | |||
| 217 | + | require.Contains(t, d.remoteCatalog.cache, proxy.key.Host, "warm expiry must preserve discovery cache") | |||
| 218 | + | d.remoteCatalog.mu.Unlock() | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) { | |||
| 222 | + | clock := newProxyLifecycleClock() | |||
| 223 | + | d, proxy, _ := newRegisteredLifecycleProxy(t, clock) | |||
| 224 | + | ||||
| 225 | + | d.armProxyWarm(proxy) | |||
| 226 | + | firstTimer := awaitTestValue(t, clock.timers, "first proxy timer was not registered") | |||
| 227 | + | proxy.mu.Lock() | |||
| 228 | + | firstToken := proxy.warm | |||
| 229 | + | proxy.mu.Unlock() | |||
| 230 | + | require.NotNil(t, firstToken) | |||
| 231 | + | ||||
| 232 | + | ac := &attachedClient{} | |||
| 233 | + | proxy.sessionCore.mu.Lock() | |||
| 234 | + | proxy.client = ac | |||
| 235 | + | proxy.sessionCore.mu.Unlock() | |||
| 236 | + | d.cancelProxyWarmForAttachment(proxy, ac) | |||
| 237 | + | awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete") | |||
| 238 | + | firstTimer.fire() | |||
| 239 | + | ||||
| 240 | + | d.mu.Lock() | |||
| 241 | + | require.Same(t, proxy, d.sessions[proxy.id]) | |||
| 242 | + | d.mu.Unlock() | |||
| 243 | + | ||||
| 244 | + | proxy.sessionCore.mu.Lock() | |||
| 245 | + | proxy.client = nil | |||
| 246 | + | proxy.sessionCore.mu.Unlock() | |||
| 247 | + | d.armProxyWarm(proxy) | |||
| 248 | + | secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered") | |||
| 249 | + | proxy.mu.Lock() | |||
| 250 | + | secondToken := proxy.warm | |||
| 251 | + | proxy.mu.Unlock() | |||
| 252 | + | require.NotNil(t, secondToken) | |||
| 253 | + | require.NotSame(t, firstToken, secondToken) | |||
| 254 | + | ||||
| 255 | + | secondTimer.fire() | |||
| 256 | + | awaitTestCompletion(t, secondToken.done, "second proxy warm lifecycle did not complete") | |||
| 257 | + | d.mu.Lock() | |||
| 258 | + | require.NotContains(t, d.sessions, proxy.id) | |||
| 259 | + | d.mu.Unlock() | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func TestProxyWarmTimerRejectsReplacementPointer(t *testing.T) { | |||
| 263 | + | clock := newProxyLifecycleClock() | |||
| 264 | + | d, proxy, transport := newRegisteredLifecycleProxy(t, clock) | |||
| 265 | + | d.armProxyWarm(proxy) | |||
| 266 | + | timer := awaitTestValue(t, clock.timers, "proxy timer was not registered") | |||
| 267 | + | proxy.mu.Lock() | |||
| 268 | + | token := proxy.warm | |||
| 269 | + | proxy.mu.Unlock() | |||
| 270 | + | ||||
| 271 | + | replacement, err := newProxySession(proxy.key, defaultSize) | |||
| 272 | + | require.NoError(t, err) | |||
| 273 | + | d.mu.Lock() | |||
| 274 | + | d.sessions[proxy.id] = replacement | |||
| 275 | + | d.mu.Unlock() | |||
| 276 | + | ||||
| 277 | + | timer.fire() | |||
| 278 | + | awaitTestCompletion(t, token.done, "proxy warm lifecycle did not complete") | |||
| 279 | + | d.mu.Lock() | |||
| 280 | + | require.Same(t, replacement, d.sessions[proxy.id]) | |||
| 281 | + | d.mu.Unlock() | |||
| 282 | + | select { | |||
| 283 | + | case <-transport.closed: | |||
| 284 | + | t.Fatal("stale pointer timer closed the old transport") | |||
| 285 | + | default: | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | type proxyResumeTestFactory struct { | |||
| 290 | + | mu sync.Mutex | |||
| 291 | + | transports []ports.Transport | |||
| 292 | + | callCount int | |||
| 293 | + | calls chan int | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func newProxyResumeTestFactory(transports ...ports.Transport) *proxyResumeTestFactory { | |||
| 297 | + | return &proxyResumeTestFactory{transports: transports, calls: make(chan int, proxyResumeMaxAttempts+2)} | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func (f *proxyResumeTestFactory) DialerForRemote(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error) { | |||
| 301 | + | f.mu.Lock() | |||
| 302 | + | f.callCount++ | |||
| 303 | + | callNumber := f.callCount | |||
| 304 | + | var selected ports.Transport | |||
| 305 | + | for i, transport := range f.transports { | |||
| 306 | + | if transport != nil { | |||
| 307 | + | selected = transport | |||
| 308 | + | f.transports[i] = nil | |||
| 309 | + | break | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | f.mu.Unlock() | |||
| 313 | + | f.calls <- callNumber | |||
| 314 | + | if selected == nil { | |||
| 315 | + | return nil, errors.New("resume dial unavailable") | |||
| 316 | + | } | |||
| 317 | + | return proxyConstructionDialer{transport: selected}, nil | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func newProxyResumeHarness(t *testing.T, clock *proxyLifecycleClock, factory ports.RemoteDialerFactory) (*Daemon, *proxySession, *proxyTestTransport) { | |||
| 321 | + | t.Helper() | |||
| 322 | + | d := newTestDaemon(t, nil, clock) | |||
| 323 | + | d.remoteDialerFactory = factory | |||
| 324 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize) | |||
| 325 | + | require.NoError(t, err) | |||
| 326 | + | initial := newProxyTestTransport() | |||
| 327 | + | proxy.mu.Lock() | |||
| 328 | + | proxy.transport = initial | |||
| 329 | + | proxy.linkGeneration = 1 | |||
| 330 | + | proxy.mu.Unlock() | |||
| 331 | + | d.mu.Lock() | |||
| 332 | + | require.True(t, d.registerSessionLocked(proxy)) | |||
| 333 | + | d.mu.Unlock() | |||
| 334 | + | return d, proxy, initial | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func TestProxyResumeBackoffIsFakeClockBoundedAndParksOffline(t *testing.T) { | |||
| 338 | + | clock := newProxyLifecycleClock() | |||
| 339 | + | factory := newProxyResumeTestFactory() | |||
| 340 | + | d, proxy, initial := newProxyResumeHarness(t, clock, factory) | |||
| 341 | + | initial.recv <- proxyRecv{err: io.ErrUnexpectedEOF} | |||
| 342 | + | ||||
| 343 | + | go d.runProxyLink(context.Background(), proxy) | |||
| 344 | + | for attempt := range proxyResumeMaxAttempts { | |||
| 345 | + | timer := awaitTestValue(t, clock.timers, "proxy resume did not arm fake-clock backoff") | |||
| 346 | + | require.Equal(t, proxyResumeBackoff(attempt), timer.delay) | |||
| 347 | + | timer.fire() | |||
| 348 | + | require.Equal(t, attempt+1, awaitTestValue(t, factory.calls, "proxy resume dial was not attempted")) | |||
| 349 | + | } | |||
| 350 | + | awaitTestCompletion(t, proxy.done, "proxy resume did not stop after its attempt cap") | |||
| 351 | + | ||||
| 352 | + | proxy.mu.Lock() | |||
| 353 | + | require.Nil(t, proxy.transport) | |||
| 354 | + | require.Equal(t, ports.LinkStateOffline, proxy.linkState) | |||
| 355 | + | proxy.mu.Unlock() | |||
| 356 | + | d.mu.Lock() | |||
| 357 | + | require.Same(t, proxy, d.sessions[proxy.id], "exhaustion must park the proxy instead of unregistering it") | |||
| 358 | + | d.mu.Unlock() | |||
| 359 | + | select { | |||
| 360 | + | case timer := <-clock.timers: | |||
| 361 | + | t.Fatalf("attempt cap armed an extra retry: %v", timer.delay) | |||
| 362 | + | default: | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func TestProxyResumeCancellationDuringBackoffDoesNotRedial(t *testing.T) { | |||
| 367 | + | clock := newProxyLifecycleClock() | |||
| 368 | + | factory := newProxyResumeTestFactory() | |||
| 369 | + | d, proxy, initial := newProxyResumeHarness(t, clock, factory) | |||
| 370 | + | initial.recv <- proxyRecv{err: io.EOF} | |||
| 371 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 372 | + | go d.runProxyLink(ctx, proxy) | |||
| 373 | + | _ = awaitTestValue(t, clock.timers, "proxy resume did not arm backoff") | |||
| 374 | + | cancel() | |||
| 375 | + | awaitTestCompletion(t, proxy.done, "canceled proxy resume did not stop") | |||
| 376 | + | select { | |||
| 377 | + | case <-factory.calls: | |||
| 378 | + | t.Fatal("canceled backoff dialed a replacement link") | |||
| 379 | + | default: | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func TestProxyResumeBackoffResetsAfterStableConnection(t *testing.T) { | |||
| 384 | + | clock := newProxyLifecycleClock() | |||
| 385 | + | resumed := newProxyTestTransport() | |||
| 386 | + | factory := newProxyResumeTestFactory(resumed) | |||
| 387 | + | d, proxy, initial := newProxyResumeHarness(t, clock, factory) | |||
| 388 | + | initial.recv <- proxyRecv{err: io.EOF} | |||
| 389 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 390 | + | defer cancel() | |||
| 391 | + | go d.runProxyLink(ctx, proxy) | |||
| 392 | + | ||||
| 393 | + | first := awaitTestValue(t, clock.timers, "initial resume did not arm backoff") | |||
| 394 | + | require.Equal(t, proxyResumeInitialBackoff, first.delay) | |||
| 395 | + | first.fire() | |||
| 396 | + | _ = awaitTestValue(t, factory.calls, "stable resume did not dial") | |||
| 397 | + | _ = requireProxyHello(t, resumed) | |||
| 398 | + | resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume|ports.CapabilityProxied)} | |||
| 399 | + | resumed.recv <- proxyRecv{frame: proxyMeta(proxy.key.Name)} | |||
| 400 | + | require.Eventually(t, func() bool { | |||
| 401 | + | proxy.mu.Lock() | |||
| 402 | + | defer proxy.mu.Unlock() | |||
| 403 | + | return proxy.transport == resumed && proxy.resumeToken == 2 | |||
| 404 | + | }, time.Second, time.Millisecond) | |||
| 405 | + | ||||
| 406 | + | clock.Advance(proxyResumeStableDuration) | |||
| 407 | + | resumed.recv <- proxyRecv{err: io.EOF} | |||
| 408 | + | second := awaitTestValue(t, clock.timers, "stable link loss did not arm another backoff") | |||
| 409 | + | require.Equal(t, proxyResumeInitialBackoff, second.delay, "stable connection must reset exponential backoff") | |||
| 410 | + | cancel() | |||
| 411 | + | awaitTestCompletion(t, proxy.done, "stable-reset proxy did not stop after cancellation") | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) { | |||
| 415 | + | clock := newProxyLifecycleClock() | |||
| 416 | + | d, proxy, transport := newRegisteredLifecycleProxy(t, clock) | |||
| 417 | + | ||||
| 418 | + | tests := []struct { | |||
| 419 | + | state ports.LinkState | |||
| 420 | + | want string | |||
| 421 | + | }{ | |||
| 422 | + | {ports.LinkStateConnected, proxy.name}, | |||
| 423 | + | {ports.LinkStateDegraded, proxy.name + " [degraded]"}, | |||
| 424 | + | {ports.LinkStateProbing, proxy.name + " [probing]"}, | |||
| 425 | + | {ports.LinkStateOffline, proxy.name + " [offline]"}, | |||
| 426 | + | {ports.LinkStateDead, proxy.name + " [dead]"}, | |||
| 427 | + | } | |||
| 428 | + | for _, test := range tests { | |||
| 429 | + | require.True(t, d.updateProxyLinkState(proxy, 1, transport, test.state)) | |||
| 430 | + | require.Equal(t, test.want, proxy.statusSegments(false).session) | |||
| 431 | + | } | |||
| 432 | + | require.False(t, d.updateProxyLinkState(proxy, 2, transport, ports.LinkStateConnected)) | |||
| 433 | + | require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session) | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) { | |||
| 437 | + | d := newTestDaemon(t, nil, newProxyLifecycleClock()) | |||
| 438 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}} | |||
| 439 | + | oldTransport := newProxyTestTransport() | |||
| 440 | + | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true} | |||
| 441 | + | old.initOverlays() | |||
| 442 | + | old.setSession(sess) | |||
| 443 | + | sess.client = old | |||
| 444 | + | d.mu.Lock() | |||
| 445 | + | require.True(t, d.registerSessionLocked(sess)) | |||
| 446 | + | d.mu.Unlock() | |||
| 447 | + | d.attachCoordinator(sess, nil, old, true) | |||
| 448 | + | ||||
| 449 | + | newTransport := newProxyTestTransport() | |||
| 450 | + | next := &attachedClient{tr: newTransport, output: newOutputStateStream()} | |||
| 451 | + | next.initOverlays() | |||
| 452 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |||
| 453 | + | target: sess, next: next, | |||
| 454 | + | expectedRole: attachmentDetached, targetRole: attachmentActive, | |||
| 455 | + | expectedTransport: next.transportSnapshot(), ready: true, | |||
| 456 | + | }) | |||
| 457 | + | require.NoError(t, err) | |||
| 458 | + | require.Nil(t, result.displaced.ac, "a proxied attachment must never enter the snatched role") | |||
| 459 | + | require.Same(t, next, sess.client) | |||
| 460 | + | require.Nil(t, old.currentAttachmentSession()) | |||
| 461 | + | ||||
| 462 | + | d.deferAttachmentTransitionCleanups(result) | |||
| 463 | + | d.waitNotifies() | |||
| 464 | + | frame := awaitTestValue(t, oldTransport.sent, "replacement did not notify old transport") | |||
| 465 | + | detached, err := ports.UnmarshalDetached(frame.Payload) | |||
| 466 | + | require.NoError(t, err) | |||
| 467 | + | require.Equal(t, ports.ReasonReplaced, detached.Reason) | |||
| 468 | + | select { | |||
| 469 | + | case <-oldTransport.closed: | |||
| 470 | + | default: | |||
| 471 | + | t.Fatal("terminal proxied replacement did not close its transport") | |||
| 472 | + | } | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | func TestProxyReplacedIsTerminalExpired(t *testing.T) { | |||
| 476 | + | clock := newProxyLifecycleClock() | |||
| 477 | + | d, proxy, transport := newRegisteredLifecycleProxy(t, clock) | |||
| 478 | + | ||||
| 479 | + | require.True(t, d.markProxyReplaced(proxy, 1, transport)) | |||
| 480 | + | proxy.mu.Lock() | |||
| 481 | + | require.True(t, proxy.expired) | |||
| 482 | + | proxy.mu.Unlock() | |||
| 483 | + | require.Equal(t, proxy.name+" [expired]", proxy.statusSegments(false).session) | |||
| 484 | + | require.False(t, d.updateProxyLinkState(proxy, 1, transport, ports.LinkStateConnected), "replacement expiry must reject later link recovery") | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func TestRemoteProxyPickerUsesExplicitExpiredLifecycleState(t *testing.T) { | |||
| 488 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 489 | + | spoofed := remoteProxyPickerView(key, sessionView{name: key.Display() + " [expired]", tabCount: 1}) | |||
| 490 | + | require.Equal(t, key.Display(), spoofed.Name) | |||
| 491 | + | require.Equal(t, picker.RemoteFresh, spoofed.RemoteAvailability) | |||
| 492 | + | require.True(t, spoofed.RemoteAttachReady) | |||
| 493 | + | ||||
| 494 | + | expired := remoteProxyPickerView(key, sessionView{name: "unrelated presentation", tabCount: 1, expired: true}) | |||
| 495 | + | require.Equal(t, key.Display()+" [expired]", expired.Name) | |||
| 496 | + | require.Equal(t, picker.RemoteStale, expired.RemoteAvailability) | |||
| 497 | + | require.False(t, expired.RemoteAttachReady) | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) { | |||
| 501 | + | clock := newProxyLifecycleClock() | |||
| 502 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 503 | + | d.clock = clock | |||
| 504 | + | coordinator := d.attachCoordinator(local, nil, ac, true) | |||
| 505 | + | localToken := attachmentToken(local, ac, ac.transport()) | |||
| 506 | + | localToken.lease = coordinator.attachmentLease(ac) | |||
| 507 | + | ||||
| 508 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 509 | + | proxy, err := newProxySession(key, ac.size) | |||
| 510 | + | require.NoError(t, err) | |||
| 511 | + | transport := &proxyLifecycleTransport{d: d, proxy: proxy, closed: make(chan struct{})} | |||
| 512 | + | proxy.transport = transport | |||
| 513 | + | proxy.linkGeneration = 1 | |||
| 514 | + | factory := newProxyConstructionFactory(newProxyTestTransport()) | |||
| 515 | + | d.remoteDialerFactory = factory | |||
| 516 | + | d.mu.Lock() | |||
| 517 | + | require.True(t, d.registerSessionLocked(proxy)) | |||
| 518 | + | d.mu.Unlock() | |||
| 519 | + | ||||
| 520 | + | toProxy, err := d.transitionAttachment(attachmentTransitionRequest{ | |||
| 521 | + | source: local, target: proxy, next: ac, | |||
| 522 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |||
| 523 | + | expectedTransport: ac.transportSnapshot(), sourceToken: &localToken, | |||
| 524 | + | action: "replaced picker regression", ready: true, | |||
| 525 | + | }) | |||
| 526 | + | require.NoError(t, err) | |||
| 527 | + | ||||
| 528 | + | result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{ | |||
| 529 | + | Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced}), | |||
| 530 | + | }) | |||
| 531 | + | require.NoError(t, err) | |||
| 532 | + | require.Equal(t, proxyLinkReplaced, result) | |||
| 533 | + | require.True(t, d.markProxyReplaced(proxy, proxy.linkGeneration, transport)) | |||
| 534 | + | ||||
| 535 | + | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |||
| 536 | + | source: proxy, target: local, next: ac, | |||
| 537 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |||
| 538 | + | expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published, | |||
| 539 | + | action: "replaced picker regression", ready: true, | |||
| 540 | + | }) | |||
| 541 | + | require.NoError(t, err) | |||
| 542 | + | require.Same(t, local, ac.currentAttachmentSession()) | |||
| 543 | + | proxy.mu.Lock() | |||
| 544 | + | warm := proxy.warm | |||
| 545 | + | proxy.mu.Unlock() | |||
| 546 | + | require.NotNil(t, warm, "switch-away did not retain warm expiry") | |||
| 547 | + | warmTimer, ok := warm.timer.(*proxyLifecycleTimer) | |||
| 548 | + | require.True(t, ok) | |||
| 549 | + | require.Equal(t, proxyWarmDuration, warmTimer.delay) | |||
| 550 | + | ||||
| 551 | + | views, _ := d.pickerViews(local) | |||
| 552 | + | var expiredView picker.SessionView | |||
| 553 | + | for _, view := range views { | |||
| 554 | + | if view.RemoteKey != nil && *view.RemoteKey == key { | |||
| 555 | + | expiredView = view | |||
| 556 | + | break | |||
| 557 | + | } | |||
| 558 | + | } | |||
| 559 | + | require.Equal(t, key.ID(), expiredView.ID, "exact registered proxy was absent from unified picker") | |||
| 560 | + | require.Equal(t, key.Display()+" [expired]", expiredView.Name) | |||
| 561 | + | require.Equal(t, picker.RemoteStale, expiredView.RemoteAvailability) | |||
| 562 | + | require.Equal(t, "expired", expiredView.RemoteDetail) | |||
| 563 | + | require.False(t, expiredView.RemoteAttachReady) | |||
| 564 | + | require.Equal(t, key.Display()+" [expired]", proxy.lifecycleDisplayName()) | |||
| 565 | + | ||||
| 566 | + | d.enterPicker(local, ac) | |||
| 567 | + | forced := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{ | |||
| 568 | + | Session: key.ID(), RemoteKey: &key, | |||
| 569 | + | }) | |||
| 570 | + | ac.overlays.pickerMu.Lock() | |||
| 571 | + | ac.overlays.picker = forced | |||
| 572 | + | cursor, cursorOK := forced.Cursor() | |||
| 573 | + | _, selectable := forced.Selected() | |||
| 574 | + | ac.overlays.pickerMu.Unlock() | |||
| 575 | + | require.True(t, cursorOK) | |||
| 576 | + | require.Equal(t, key, *cursor.RemoteKey) | |||
| 577 | + | require.False(t, selectable, "terminal proxy row must not be activatable") | |||
| 578 | + | d.handlePickerInput(ac, []byte("\r")) | |||
| 579 | + | require.False(t, ac.overlays.pickerActive()) | |||
| 580 | + | require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy") | |||
| 581 | + | ||||
| 582 | + | token := attachmentToken(local, ac, ac.transport()) | |||
| 583 | + | effect, admitted := ac.beginRoleEffect(token) | |||
| 584 | + | require.True(t, admitted) | |||
| 585 | + | token.effect = effect | |||
| 586 | + | err = d.switchToTargetForRole(token, cursor, sessionHandoffGuard{}, "replaced picker regression") | |||
| 587 | + | require.Error(t, err) | |||
| 588 | + | require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy") | |||
| 589 | + | ||||
| 590 | + | reopened, err := d.openProxySession(context.Background(), key, ac.size) | |||
| 591 | + | require.Error(t, err) | |||
| 592 | + | require.Nil(t, reopened) | |||
| 593 | + | require.Equal(t, 0, factory.callCount(), "terminal proxy triggered an automatic IntentAttach/redial") | |||
| 594 | + | d.mu.Lock() | |||
| 595 | + | require.Same(t, proxy, d.sessions[key.ID()], "terminal proxy registration changed before warm expiry") | |||
| 596 | + | d.mu.Unlock() | |||
| 597 | + | ||||
| 598 | + | proxy.mu.Lock() | |||
| 599 | + | require.Same(t, warm, proxy.warm) | |||
| 600 | + | proxy.mu.Unlock() | |||
| 601 | + | warmTimer.fire() | |||
| 602 | + | awaitTestCompletion(t, warm.done, "terminal proxy warm expiry did not complete") | |||
| 603 | + | } | |||
| 604 | + | ||||
| 605 | + | func TestDormantProxyReplacedRetainsAutomaticWarmExpiry(t *testing.T) { | |||
| 606 | + | clock := newProxyLifecycleClock() | |||
| 607 | + | d, proxy, _ := newRegisteredLifecycleProxy(t, clock) | |||
| 608 | + | link := newProxyKillControlTransport(d, proxy) | |||
| 609 | + | proxy.mu.Lock() | |||
| 610 | + | proxy.transport = link | |||
| 611 | + | proxy.mu.Unlock() | |||
| 612 | + | ||||
| 613 | + | require.True(t, d.armProxyWarm(proxy)) | |||
| 614 | + | timer := awaitTestValue(t, clock.timers, "dormant proxy did not arm its warm deadline") | |||
| 615 | + | proxy.mu.Lock() | |||
| 616 | + | token := proxy.warm | |||
| 617 | + | generation := proxy.generation | |||
| 618 | + | proxy.mu.Unlock() | |||
| 619 | + | require.NotNil(t, token) | |||
| 620 | + | require.Equal(t, proxyWarmDuration, timer.delay) | |||
| 621 | + | ||||
| 622 | + | link.recv <- proxyKillRecv{frame: ports.Frame{ | |||
| 623 | + | Type: ports.MsgDetached, | |||
| 624 | + | Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced}), | |||
| 625 | + | }} | |||
| 626 | + | go d.runProxyLink(context.Background(), proxy) | |||
| 627 | + | awaitTestCompletion(t, proxy.done, "replacement did not terminate the proxy link") | |||
| 628 | + | ||||
| 629 | + | proxy.mu.Lock() | |||
| 630 | + | require.True(t, proxy.expired) | |||
| 631 | + | require.Same(t, token, proxy.warm, "terminal replacement cleared the only expiry capability") | |||
| 632 | + | require.Greater(t, proxy.generation, generation, "terminal replacement did not invalidate stale lifecycle observers") | |||
| 633 | + | require.Equal(t, proxy.generation, token.generation, "preserved expiry capability was not rekeyed to the terminal generation") | |||
| 634 | + | proxy.mu.Unlock() | |||
| 635 | + | select { | |||
| 636 | + | case extra := <-clock.timers: | |||
| 637 | + | t.Fatalf("replacement unexpectedly rearmed the original deadline: %v", extra.delay) | |||
| 638 | + | default: | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | timer.fire() | |||
| 642 | + | awaitTestCompletion(t, token.done, "original warm deadline did not complete") | |||
| 643 | + | d.mu.Lock() | |||
| 644 | + | require.NotContains(t, d.sessions, proxy.id, "exact replaced proxy was not automatically unregistered") | |||
| 645 | + | d.mu.Unlock() | |||
| 646 | + | } | |||
| 647 | + | ||||
| 648 | + | type proxyKillRecv struct { | |||
| 649 | + | frame ports.Frame | |||
| 650 | + | err error | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | type proxyKillControlTransport struct { | |||
| 654 | + | d *Daemon | |||
| 655 | + | proxy *proxySession | |||
| 656 | + | sent chan ports.Frame | |||
| 657 | + | recv chan proxyKillRecv | |||
| 658 | + | recvLocksChecked chan struct{} | |||
| 659 | + | closed chan struct{} | |||
| 660 | + | close sync.Once | |||
| 661 | + | recvCheck sync.Once | |||
| 662 | + | sendErr error | |||
| 663 | + | } | |||
| 664 | + | ||||
| 665 | + | func newProxyKillControlTransport(d *Daemon, proxy *proxySession) *proxyKillControlTransport { | |||
| 666 | + | return &proxyKillControlTransport{ | |||
| 667 | + | d: d, proxy: proxy, sent: make(chan ports.Frame, 2), recv: make(chan proxyKillRecv, 1), | |||
| 668 | + | recvLocksChecked: make(chan struct{}), closed: make(chan struct{}), | |||
| 669 | + | } | |||
| 670 | + | } | |||
| 671 | + | ||||
| 672 | + | func (t *proxyKillControlTransport) locksAvailable() bool { | |||
| 673 | + | daemonUnlocked := t.d.mu.TryLock() | |||
| 674 | + | if daemonUnlocked { | |||
| 675 | + | t.d.mu.Unlock() | |||
| 676 | + | } | |||
| 677 | + | coreUnlocked := t.proxy.sessionCore.mu.TryLock() | |||
| 678 | + | if coreUnlocked { | |||
| 679 | + | t.proxy.sessionCore.mu.Unlock() | |||
| 680 | + | } | |||
| 681 | + | proxyUnlocked := t.proxy.mu.TryLock() | |||
| 682 | + | if proxyUnlocked { | |||
| 683 | + | t.proxy.mu.Unlock() | |||
| 684 | + | } | |||
| 685 | + | catalogUnlocked := t.d.remoteCatalog.mu.TryLock() | |||
| 686 | + | if catalogUnlocked { | |||
| 687 | + | t.d.remoteCatalog.mu.Unlock() | |||
| 688 | + | } | |||
| 689 | + | return daemonUnlocked && coreUnlocked && proxyUnlocked && catalogUnlocked | |||
| 690 | + | } | |||
| 691 | + | ||||
| 692 | + | func (t *proxyKillControlTransport) Send(frame ports.Frame) error { | |||
| 693 | + | if !t.locksAvailable() { | |||
| 694 | + | return errors.New("architecture lock held during control send") | |||
| 695 | + | } | |||
| 696 | + | t.sent <- frame | |||
| 697 | + | return t.sendErr | |||
| 698 | + | } | |||
| 699 | + | ||||
| 700 | + | func (t *proxyKillControlTransport) Recv() (ports.Frame, error) { | |||
| 701 | + | if !t.locksAvailable() { | |||
| 702 | + | return ports.Frame{}, errors.New("architecture lock held during control receive") | |||
| 703 | + | } | |||
| 704 | + | t.recvCheck.Do(func() { close(t.recvLocksChecked) }) | |||
| 705 | + | select { | |||
| 706 | + | case result := <-t.recv: | |||
| 707 | + | return result.frame, result.err | |||
| 708 | + | case <-t.closed: | |||
| 709 | + | return ports.Frame{}, io.EOF | |||
| 710 | + | } | |||
| 711 | + | } | |||
| 712 | + | ||||
| 713 | + | func (t *proxyKillControlTransport) Close() error { | |||
| 714 | + | if !t.locksAvailable() { | |||
| 715 | + | return errors.New("architecture lock held during control close") | |||
| 716 | + | } | |||
| 717 | + | t.close.Do(func() { close(t.closed) }) | |||
| 718 | + | return nil | |||
| 719 | + | } | |||
| 720 | + | ||||
| 721 | + | type proxyKillBlockedSendTransport struct { | |||
| 722 | + | *proxyKillControlTransport | |||
| 723 | + | started chan struct{} | |||
| 724 | + | release chan struct{} | |||
| 725 | + | finished chan struct{} | |||
| 726 | + | start sync.Once | |||
| 727 | + | finish sync.Once | |||
| 728 | + | } | |||
| 729 | + | ||||
| 730 | + | func (t *proxyKillBlockedSendTransport) Send(ports.Frame) error { | |||
| 731 | + | if !t.locksAvailable() { | |||
| 732 | + | return errors.New("architecture lock held during blocked control send") | |||
| 733 | + | } | |||
| 734 | + | t.start.Do(func() { close(t.started) }) | |||
| 735 | + | <-t.release | |||
| 736 | + | t.finish.Do(func() { close(t.finished) }) | |||
| 737 | + | return nil | |||
| 738 | + | } | |||
| 739 | + | ||||
| 740 | + | type proxyKillDialer struct { | |||
| 741 | + | d *Daemon | |||
| 742 | + | proxy *proxySession | |||
| 743 | + | transport ports.Transport | |||
| 744 | + | err error | |||
| 745 | + | } | |||
| 746 | + | ||||
| 747 | + | func (d proxyKillDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 748 | + | if ctx == nil { | |||
| 749 | + | return nil, errors.New("nil kill context") | |||
| 750 | + | } | |||
| 751 | + | checker := newProxyKillControlTransport(d.d, d.proxy) | |||
| 752 | + | if !checker.locksAvailable() { | |||
| 753 | + | return nil, errors.New("architecture lock held during control dial") | |||
| 754 | + | } | |||
| 755 | + | return d.transport, d.err | |||
| 756 | + | } | |||
| 757 | + | ||||
| 758 | + | type proxyKillDialerFactory struct { | |||
| 759 | + | d *Daemon | |||
| 760 | + | proxy *proxySession | |||
| 761 | + | dialer ports.Dialer | |||
| 762 | + | err error | |||
| 763 | + | targets chan domain.RemoteSessionKey | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | func (f *proxyKillDialerFactory) DialerForRemote(target, session string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) { | |||
| 767 | + | checker := newProxyKillControlTransport(f.d, f.proxy) | |||
| 768 | + | if !checker.locksAvailable() { | |||
| 769 | + | return nil, errors.New("architecture lock held during dialer selection") | |||
| 770 | + | } | |||
| 771 | + | if f.targets != nil { | |||
| 772 | + | f.targets <- domain.RemoteSessionKey{Host: target, Name: session} | |||
| 773 | + | } | |||
| 774 | + | return f.dialer, f.err | |||
| 775 | + | } | |||
| 776 | + | ||||
| 777 | + | type proxyKillCache struct { | |||
| 778 | + | d *Daemon | |||
| 779 | + | proxy *proxySession | |||
| 780 | + | stores chan []ports.RemoteCatalogCacheEntry | |||
| 781 | + | } | |||
| 782 | + | ||||
| 783 | + | func (*proxyKillCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil } | |||
| 784 | + | func (c *proxyKillCache) Store(entries []ports.RemoteCatalogCacheEntry) error { | |||
| 785 | + | checker := newProxyKillControlTransport(c.d, c.proxy) | |||
| 786 | + | if !checker.locksAvailable() { | |||
| 787 | + | return errors.New("architecture lock held during cache persistence") | |||
| 788 | + | } | |||
| 789 | + | copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries)) | |||
| 790 | + | for i, entry := range entries { | |||
| 791 | + | copyEntries[i] = cloneRemoteCatalogEntry(entry) | |||
| 792 | + | } | |||
| 793 | + | c.stores <- copyEntries | |||
| 794 | + | return nil | |||
| 795 | + | } | |||
| 796 | + | ||||
| 797 | + | func newProxyKillHarness(t *testing.T) (*Daemon, *proxySession, *proxyLifecycleClock, *proxyKillControlTransport, *proxyKillCache) { | |||
| 798 | + | t.Helper() | |||
| 799 | + | clock := newProxyLifecycleClock() | |||
| 800 | + | d, proxy, _ := newRegisteredLifecycleProxy(t, clock) | |||
| 801 | + | control := newProxyKillControlTransport(d, proxy) | |||
| 802 | + | factory := &proxyKillDialerFactory{d: d, proxy: proxy, targets: make(chan domain.RemoteSessionKey, 1)} | |||
| 803 | + | factory.dialer = proxyKillDialer{d: d, proxy: proxy, transport: control} | |||
| 804 | + | d.remoteDialerFactory = factory | |||
| 805 | + | d.remoteTransportMode = ports.RemoteTransportUDP | |||
| 806 | + | cache := &proxyKillCache{d: d, proxy: proxy, stores: make(chan []ports.RemoteCatalogCacheEntry, 2)} | |||
| 807 | + | d.remoteCatalogCache = cache | |||
| 808 | + | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |||
| 809 | + | Host: proxy.key.Host, FetchedAt: clock.Now(), Sessions: []ports.RemoteCatalogSession{ | |||
| 810 | + | {Name: proxy.key.Name, State: "running"}, {Name: "other", State: "running"}, | |||
| 811 | + | }, | |||
| 812 | + | }}) | |||
| 813 | + | return d, proxy, clock, control, cache | |||
| 814 | + | } | |||
| 815 | + | ||||
| 816 | + | func proxyKillGeneration(p *proxySession) uint64 { | |||
| 817 | + | p.mu.Lock() | |||
| 818 | + | defer p.mu.Unlock() | |||
| 819 | + | return p.generation | |||
| 820 | + | } | |||
| 821 | + | ||||
| 822 | + | func requireProxyKillState(t *testing.T, d *Daemon, proxy *proxySession, present bool) { | |||
| 823 | + | t.Helper() | |||
| 824 | + | d.mu.Lock() | |||
| 825 | + | require.Equal(t, present, d.sessions[proxy.id] == proxy) | |||
| 826 | + | d.mu.Unlock() | |||
| 827 | + | d.remoteCatalog.mu.Lock() | |||
| 828 | + | entry := d.remoteCatalog.cache[proxy.key.Host] | |||
| 829 | + | d.remoteCatalog.mu.Unlock() | |||
| 830 | + | found := false | |||
| 831 | + | for _, session := range entry.Sessions { | |||
| 832 | + | found = found || session.Name == proxy.key.Name | |||
| 833 | + | } | |||
| 834 | + | require.Equal(t, present, found) | |||
| 835 | + | } | |||
| 836 | + | ||||
| 837 | + | func TestProxyKillEOFSuccessRemovesExactLiveAndCacheWithoutLockedIO(t *testing.T) { | |||
| 838 | + | d, proxy, _, control, cache := newProxyKillHarness(t) | |||
| 839 | + | proxy.mu.Lock() | |||
| 840 | + | liveTransport, ok := proxy.transport.(*proxyLifecycleTransport) | |||
| 841 | + | liveGeneration := proxy.generation | |||
| 842 | + | proxy.mu.Unlock() | |||
| 843 | + | require.True(t, ok) | |||
| 844 | + | control.recv <- proxyKillRecv{err: fmt.Errorf("ssh stream closed: %w", io.EOF)} | |||
| 845 | + | ||||
| 846 | + | require.NoError(t, d.killRemoteProxy(context.Background(), proxy, liveGeneration)) | |||
| 847 | + | frame := awaitTestValue(t, control.sent, "remote control kill was not sent") | |||
| 848 | + | require.Equal(t, ports.MsgKill, frame.Type) | |||
| 849 | + | kill, err := ports.UnmarshalKill(frame.Payload) | |||
| 850 | + | require.NoError(t, err) | |||
| 851 | + | require.Equal(t, ports.Kill{Name: proxy.key.Name}, kill) | |||
| 852 | + | requireProxyKillState(t, d, proxy, false) | |||
| 853 | + | awaitTestCompletion(t, liveTransport.closed, "successful remote kill did not close the exact live transport generation") | |||
| 854 | + | require.True(t, liveTransport.unlocked, "exact live transport close ran under an architecture lock") | |||
| 855 | + | proxy.mu.Lock() | |||
| 856 | + | require.Greater(t, proxy.generation, liveGeneration) | |||
| 857 | + | require.Same(t, liveTransport, proxy.transport, "teardown must not substitute a different link generation before closing it") | |||
| 858 | + | proxy.mu.Unlock() | |||
| 859 | + | stored := awaitTestValue(t, cache.stores, "successful remote kill did not persist cache removal") | |||
| 860 | + | require.Len(t, stored, 1) | |||
| 861 | + | require.Equal(t, []ports.RemoteCatalogSession{{Name: "other", State: "running"}}, stored[0].Sessions) | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | func TestProxyKillFailuresPreserveLiveAndCache(t *testing.T) { | |||
| 865 | + | remoteFailure := ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrInternal, Text: "no"})} | |||
| 866 | + | tests := []struct { | |||
| 867 | + | name string | |||
| 868 | + | setup func(*Daemon, *proxySession, *proxyKillControlTransport) | |||
| 869 | + | }{ | |||
| 870 | + | {name: "dialer selection", setup: func(d *Daemon, proxy *proxySession, _ *proxyKillControlTransport) { | |||
| 871 | + | d.remoteDialerFactory = &proxyKillDialerFactory{d: d, proxy: proxy, err: errors.New("factory")} | |||
| 872 | + | }}, | |||
| 873 | + | {name: "dial", setup: func(d *Daemon, proxy *proxySession, _ *proxyKillControlTransport) { | |||
| 874 | + | d.remoteDialerFactory = &proxyKillDialerFactory{d: d, proxy: proxy, dialer: proxyKillDialer{d: d, proxy: proxy, err: errors.New("dial")}} | |||
| 875 | + | }}, | |||
| 876 | + | {name: "send", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) { | |||
| 877 | + | control.sendErr = errors.New("send") | |||
| 878 | + | }}, | |||
| 879 | + | {name: "receive error", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) { | |||
| 880 | + | control.recv <- proxyKillRecv{err: errors.New("truncated control reply")} | |||
| 881 | + | }}, | |||
| 882 | + | {name: "remote error", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) { | |||
| 883 | + | control.recv <- proxyKillRecv{frame: remoteFailure} | |||
| 884 | + | }}, | |||
| 885 | + | {name: "unexpected reply", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) { | |||
| 886 | + | control.recv <- proxyKillRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}} | |||
| 887 | + | }}, | |||
| 888 | + | } | |||
| 889 | + | for _, test := range tests { | |||
| 890 | + | t.Run(test.name, func(t *testing.T) { | |||
| 891 | + | d, proxy, _, control, cache := newProxyKillHarness(t) | |||
| 892 | + | test.setup(d, proxy, control) | |||
| 893 | + | require.Error(t, d.killRemoteProxy(context.Background(), proxy, proxyKillGeneration(proxy))) | |||
| 894 | + | requireProxyKillState(t, d, proxy, true) | |||
| 895 | + | select { | |||
| 896 | + | case <-cache.stores: | |||
| 897 | + | t.Fatal("failed remote kill persisted a cache mutation") | |||
| 898 | + | default: | |||
| 899 | + | } | |||
| 900 | + | }) | |||
| 901 | + | } | |||
| 902 | + | } | |||
| 903 | + | ||||
| 904 | + | func TestProxyKillTimeoutPreservesState(t *testing.T) { | |||
| 905 | + | d, proxy, clock, _, cache := newProxyKillHarness(t) | |||
| 906 | + | done := make(chan error, 1) | |||
| 907 | + | go func() { done <- d.killRemoteProxy(context.Background(), proxy, proxyKillGeneration(proxy)) }() | |||
| 908 | + | timer := awaitTestValue(t, clock.timers, "remote kill did not arm a bounded timeout") | |||
| 909 | + | require.Equal(t, proxyKillTimeout, timer.delay) | |||
| 910 | + | timer.fire() | |||
| 911 | + | require.ErrorIs(t, awaitTestValue(t, done, "remote kill timeout did not unblock receive"), errProxyKillTimeout) | |||
| 912 | + | requireProxyKillState(t, d, proxy, true) | |||
| 913 | + | select { | |||
| 914 | + | case <-cache.stores: | |||
| 915 | + | t.Fatal("timed out remote kill persisted a cache mutation") | |||
| 916 | + | default: | |||
| 917 | + | } | |||
| 918 | + | } | |||
| 919 | + | ||||
| 920 | + | func TestProxyKillBlockedSendReturnsAtBoundWithoutMutatingState(t *testing.T) { | |||
| 921 | + | d, proxy, clock, control, cache := newProxyKillHarness(t) | |||
| 922 | + | blocked := &proxyKillBlockedSendTransport{ | |||
| 923 | + | proxyKillControlTransport: control, | |||
| 924 | + | started: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{}), | |||
| 925 | + | } | |||
| 926 | + | defer func() { | |||
| 927 | + | close(blocked.release) | |||
| 928 | + | awaitTestCompletion(t, blocked.finished, "abandoned send did not finish after test release") | |||
| 929 | + | }() | |||
| 930 | + | d.remoteDialerFactory = &proxyKillDialerFactory{ | |||
| 931 | + | d: d, proxy: proxy, | |||
| 932 | + | dialer: proxyKillDialer{d: d, proxy: proxy, transport: blocked}, | |||
| 933 | + | } | |||
| 934 | + | ||||
| 935 | + | d.mu.Lock() | |||
| 936 | + | beforeLive := d.sessions[proxy.id] | |||
| 937 | + | beforeClosing := d.closing | |||
| 938 | + | d.mu.Unlock() | |||
| 939 | + | proxy.mu.Lock() | |||
| 940 | + | beforeGeneration, beforeExpired, beforeWarm, beforeTransport := proxy.generation, proxy.expired, proxy.warm, proxy.transport | |||
| 941 | + | proxy.mu.Unlock() | |||
| 942 | + | d.remoteCatalog.mu.Lock() | |||
| 943 | + | beforeCache := cloneRemoteCatalogEntry(d.remoteCatalog.cache[proxy.key.Host]) | |||
| 944 | + | d.remoteCatalog.mu.Unlock() | |||
| 945 | + | ||||
| 946 | + | done := make(chan error, 1) | |||
| 947 | + | go func() { done <- d.killRemoteProxy(context.Background(), proxy, beforeGeneration) }() | |||
| 948 | + | awaitTestCompletion(t, blocked.started, "remote kill did not enter adversarial Send") | |||
| 949 | + | timer := awaitTestValue(t, clock.timers, "remote kill did not arm a bounded timeout") | |||
| 950 | + | require.Equal(t, proxyKillTimeout, timer.delay) | |||
| 951 | + | timer.fire() | |||
| 952 | + | require.ErrorIs(t, awaitTestValue(t, done, "remote kill waited for Send after its bound"), errProxyKillTimeout) | |||
| 953 | + | ||||
| 954 | + | d.mu.Lock() | |||
| 955 | + | require.Same(t, beforeLive, d.sessions[proxy.id]) | |||
| 956 | + | require.Equal(t, beforeClosing, d.closing) | |||
| 957 | + | d.mu.Unlock() | |||
| 958 | + | proxy.mu.Lock() | |||
| 959 | + | require.Equal(t, beforeGeneration, proxy.generation) | |||
| 960 | + | require.Equal(t, beforeExpired, proxy.expired) | |||
| 961 | + | require.Same(t, beforeWarm, proxy.warm) | |||
| 962 | + | require.Same(t, beforeTransport, proxy.transport) | |||
| 963 | + | proxy.mu.Unlock() | |||
| 964 | + | d.remoteCatalog.mu.Lock() | |||
| 965 | + | require.Equal(t, beforeCache, d.remoteCatalog.cache[proxy.key.Host]) | |||
| 966 | + | d.remoteCatalog.mu.Unlock() | |||
| 967 | + | select { | |||
| 968 | + | case <-cache.stores: | |||
| 969 | + | t.Fatal("blocked remote kill persisted a cache mutation") | |||
| 970 | + | default: | |||
| 971 | + | } | |||
| 972 | + | select { | |||
| 973 | + | case <-blocked.finished: | |||
| 974 | + | t.Fatal("adversarial Send unexpectedly returned when transport closed") | |||
| 975 | + | default: | |||
| 976 | + | } | |||
| 977 | + | } | |||
| 978 | + | ||||
| 979 | + | func TestProxyKillStaleSuccessPreservesReplacementGeneration(t *testing.T) { | |||
| 980 | + | tests := []struct { | |||
| 981 | + | name string | |||
| 982 | + | win func(*Daemon, *proxySession) | |||
| 983 | + | }{ | |||
| 984 | + | {name: "reattach generation", win: func(d *Daemon, proxy *proxySession) { | |||
| 985 | + | d.mu.Lock() | |||
| 986 | + | proxy.sessionCore.mu.Lock() | |||
| 987 | + | proxy.mu.Lock() | |||
| 988 | + | proxy.generation++ | |||
| 989 | + | proxy.mu.Unlock() | |||
| 990 | + | proxy.sessionCore.mu.Unlock() | |||
| 991 | + | d.mu.Unlock() | |||
| 992 | + | }}, | |||
| 993 | + | {name: "replacement pointer", win: func(d *Daemon, proxy *proxySession) { | |||
| 994 | + | replacement, err := newProxySession(proxy.key, defaultSize) | |||
| 995 | + | require.NoError(t, err) | |||
| 996 | + | d.mu.Lock() | |||
| 997 | + | d.sessions[proxy.id] = replacement | |||
| 998 | + | d.mu.Unlock() | |||
| 999 | + | }}, | |||
| 1000 | + | } | |||
| 1001 | + | for _, test := range tests { | |||
| 1002 | + | t.Run(test.name, func(t *testing.T) { | |||
| 1003 | + | d, proxy, _, control, cache := newProxyKillHarness(t) | |||
| 1004 | + | generation := proxyKillGeneration(proxy) | |||
| 1005 | + | done := make(chan error, 1) | |||
| 1006 | + | go func() { done <- d.killRemoteProxy(context.Background(), proxy, generation) }() | |||
| 1007 | + | _ = awaitTestValue(t, control.sent, "remote kill did not reach the control transport") | |||
| 1008 | + | awaitTestCompletion(t, control.recvLocksChecked, "remote kill did not verify unlocked architecture before generation replacement") | |||
| 1009 | + | test.win(d, proxy) | |||
| 1010 | + | control.recv <- proxyKillRecv{err: io.EOF} | |||
| 1011 | + | require.ErrorIs(t, awaitTestValue(t, done, "stale remote kill did not finish"), errProxyKillGeneration) | |||
| 1012 | + | d.remoteCatalog.mu.Lock() | |||
| 1013 | + | entry := d.remoteCatalog.cache[proxy.key.Host] | |||
| 1014 | + | d.remoteCatalog.mu.Unlock() | |||
| 1015 | + | require.Len(t, entry.Sessions, 2, "stale success removed cached discovery") | |||
| 1016 | + | select { | |||
| 1017 | + | case <-cache.stores: | |||
| 1018 | + | t.Fatal("stale remote success persisted a cache mutation") | |||
| 1019 | + | default: | |||
| 1020 | + | } | |||
| 1021 | + | }) | |||
| 1022 | + | } | |||
| 1023 | + | } | |||
| 1024 | + | ||||
| 1025 | + | func TestProxyKillCurrentLinkDetachedCanRaceControlEOF(t *testing.T) { | |||
| 1026 | + | d, proxy, _, control, _ := newProxyKillHarness(t) | |||
| 1027 | + | generation := proxyKillGeneration(proxy) | |||
| 1028 | + | done := make(chan error, 1) | |||
| 1029 | + | go func() { done <- d.killRemoteProxy(context.Background(), proxy, generation) }() | |||
| 1030 | + | _ = awaitTestValue(t, control.sent, "remote kill did not reach the control transport") | |||
| 1031 | + | result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{ | |||
| 1032 | + | Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}), | |||
| 1033 | + | }) | |||
| 1034 | + | require.NoError(t, err) | |||
| 1035 | + | require.Equal(t, proxyLinkStop, result) | |||
| 1036 | + | control.recv <- proxyKillRecv{err: io.EOF} | |||
| 1037 | + | require.NoError(t, awaitTestValue(t, done, "remote kill did not accept EOF after current-link detach")) | |||
| 1038 | + | requireProxyKillState(t, d, proxy, false) | |||
| 1039 | + | } | |||
| 1040 | + | ||||
| 1041 | + | func TestProxyKillConfirmationRequiresExactRemoteDisplay(t *testing.T) { | |||
| 1042 | + | p, release := newBlockingPTY(t) | |||
| 1043 | + | defer release() | |||
| 1044 | + | d, local, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 1045 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize) | |||
| 1046 | + | require.NoError(t, err) | |||
| 1047 | + | d.mu.Lock() | |||
| 1048 | + | require.True(t, d.registerSessionLocked(proxy)) | |||
| 1049 | + | d.mu.Unlock() | |||
| 1050 | + | ||||
| 1051 | + | target := picker.Target{Session: proxy.id, RemoteKey: &proxy.key, TabIndex: -1} | |||
| 1052 | + | require.True(t, d.enterRemoteKillConfirmation(local, ac, target)) | |||
| 1053 | + | ac.overlays.promptMu.Lock() | |||
| 1054 | + | require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title()) | |||
| 1055 | + | submit := ac.overlays.promptTransitionSubmit | |||
| 1056 | + | ac.overlays.promptMu.Unlock() | |||
| 1057 | + | require.Error(t, submit("work", attachmentRoleToken{})) | |||
| 1058 | + | d.mu.Lock() | |||
| 1059 | + | require.Same(t, proxy, d.sessions[proxy.id]) | |||
| 1060 | + | d.mu.Unlock() | |||
| 1061 | + | } | |||
| 1062 | + | ||||
| 1063 | + | func TestProxyKillConfirmationWorksWhileAttachedToProxyAndCancelsWithoutIO(t *testing.T) { | |||
| 1064 | + | d, proxy, ac, _, _, _, _ := newProxyRoleDetachFixture(t) | |||
| 1065 | + | target := picker.Target{Session: proxy.id, RemoteKey: &proxy.key, TabIndex: -1} | |||
| 1066 | + | require.True(t, d.enterRemoteKillConfirmation(proxy, ac, target)) | |||
| 1067 | + | d.handlePromptInput(ac, []byte("\x03")) | |||
| 1068 | + | require.False(t, ac.overlays.promptActive()) | |||
| 1069 | + | d.mu.Lock() | |||
| 1070 | + | require.Same(t, proxy, d.sessions[proxy.id]) | |||
| 1071 | + | d.mu.Unlock() | |||
| 1072 | + | } | |||
| 1073 | + | ||||
| 1074 | + | type proxyConstructionDialer struct{ transport ports.Transport } | |||
| 1075 | + | ||||
| 1076 | + | func (d proxyConstructionDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 1077 | + | return d.transport, nil | |||
| 1078 | + | } | |||
| 1079 | + | ||||
| 1080 | + | type proxyConstructionFactory struct { | |||
| 1081 | + | mu sync.Mutex | |||
| 1082 | + | transports []ports.Transport | |||
| 1083 | + | calls chan int | |||
| 1084 | + | secondAttempt chan struct{} | |||
| 1085 | + | releaseSecond chan struct{} | |||
| 1086 | + | secondCallOnce sync.Once | |||
| 1087 | + | } | |||
| 1088 | + | ||||
| 1089 | + | func newProxyConstructionFactory(transports ...ports.Transport) *proxyConstructionFactory { | |||
| 1090 | + | return &proxyConstructionFactory{ | |||
| 1091 | + | transports: transports, calls: make(chan int, len(transports)+1), | |||
| 1092 | + | secondAttempt: make(chan struct{}), releaseSecond: make(chan struct{}), | |||
| 1093 | + | } | |||
| 1094 | + | } | |||
| 1095 | + | ||||
| 1096 | + | func (f *proxyConstructionFactory) DialerForRemote(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error) { | |||
| 1097 | + | f.mu.Lock() | |||
| 1098 | + | call := len(f.transports) | |||
| 1099 | + | for i := range f.transports { | |||
| 1100 | + | if f.transports[i] != nil { | |||
| 1101 | + | call = i | |||
| 1102 | + | break | |||
| 1103 | + | } | |||
| 1104 | + | } | |||
| 1105 | + | if call == len(f.transports) { | |||
| 1106 | + | f.mu.Unlock() | |||
| 1107 | + | return nil, errors.New("unexpected extra proxy construction") | |||
| 1108 | + | } | |||
| 1109 | + | transport := f.transports[call] | |||
| 1110 | + | f.transports[call] = nil | |||
| 1111 | + | f.mu.Unlock() | |||
| 1112 | + | f.calls <- call + 1 | |||
| 1113 | + | if call == 1 { | |||
| 1114 | + | f.secondCallOnce.Do(func() { close(f.secondAttempt) }) | |||
| 1115 | + | <-f.releaseSecond | |||
| 1116 | + | } | |||
| 1117 | + | return proxyConstructionDialer{transport: transport}, nil | |||
| 1118 | + | } | |||
| 1119 | + | ||||
| 1120 | + | func (f *proxyConstructionFactory) callCount() int { | |||
| 1121 | + | f.mu.Lock() | |||
| 1122 | + | defer f.mu.Unlock() | |||
| 1123 | + | count := 0 | |||
| 1124 | + | for _, transport := range f.transports { | |||
| 1125 | + | if transport == nil { | |||
| 1126 | + | count++ | |||
| 1127 | + | } | |||
| 1128 | + | } | |||
| 1129 | + | return count | |||
| 1130 | + | } | |||
| 1131 | + | ||||
| 1132 | + | type proxyConstructionWaitContext struct { | |||
| 1133 | + | context.Context | |||
| 1134 | + | observed chan struct{} | |||
| 1135 | + | once sync.Once | |||
| 1136 | + | } | |||
| 1137 | + | ||||
| 1138 | + | func (c *proxyConstructionWaitContext) Done() <-chan struct{} { | |||
| 1139 | + | c.once.Do(func() { close(c.observed) }) | |||
| 1140 | + | return c.Context.Done() | |||
| 1141 | + | } | |||
| 1142 | + | ||||
| 1143 | + | type proxyOpenResult struct { | |||
| 1144 | + | proxy *proxySession | |||
| 1145 | + | err error | |||
| 1146 | + | } | |||
| 1147 | + | ||||
| 1148 | + | func openProxyForTest(d *Daemon, ctx context.Context, key domain.RemoteSessionKey) <-chan proxyOpenResult { | |||
| 1149 | + | result := make(chan proxyOpenResult, 1) | |||
| 1150 | + | go func() { | |||
| 1151 | + | proxy, err := d.openProxySession(ctx, key, defaultSize) | |||
| 1152 | + | result <- proxyOpenResult{proxy: proxy, err: err} | |||
| 1153 | + | }() | |||
| 1154 | + | return result | |||
| 1155 | + | } | |||
| 1156 | + | ||||
| 1157 | + | func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) { | |||
| 1158 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 1159 | + | first := newProxyTestTransport() | |||
| 1160 | + | second := newProxyTestTransport() | |||
| 1161 | + | second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityProxied)} | |||
| 1162 | + | second.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 1163 | + | factory := newProxyConstructionFactory(first, second) | |||
| 1164 | + | d := newProxyTestDaemon(t, factory, nil) | |||
| 1165 | + | ||||
| 1166 | + | leader := openProxyForTest(d, context.Background(), key) | |||
| 1167 | + | require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial")) | |||
| 1168 | + | _ = requireProxyHello(t, first) | |||
| 1169 | + | ||||
| 1170 | + | waitCtx, cancelWait := context.WithCancel(context.Background()) | |||
| 1171 | + | defer cancelWait() | |||
| 1172 | + | observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})} | |||
| 1173 | + | waiter := openProxyForTest(d, observed, key) | |||
| 1174 | + | ||||
| 1175 | + | secondStarted := false | |||
| 1176 | + | select { | |||
| 1177 | + | case <-observed.observed: | |||
| 1178 | + | // The waiter reached the cancellable construction reservation. | |||
| 1179 | + | case <-factory.secondAttempt: | |||
| 1180 | + | // This is the adversarial pre-fix path: the later remote IntentAttach | |||
| 1181 | + | // would replace the leader before its otherwise-valid reply arrives. | |||
| 1182 | + | secondStarted = true | |||
| 1183 | + | close(factory.releaseSecond) | |||
| 1184 | + | _ = requireProxyHello(t, second) | |||
| 1185 | + | } | |||
| 1186 | + | ||||
| 1187 | + | first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 1188 | + | first.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 1189 | + | leaderResult := awaitTestValue(t, leader, "leader construction did not finish") | |||
| 1190 | + | waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish") | |||
| 1191 | + | require.NoError(t, leaderResult.err) | |||
| 1192 | + | require.NoError(t, waiterResult.err) | |||
| 1193 | + | require.False(t, secondStarted, "a second same-key IntentAttach handshake started") | |||
| 1194 | + | require.Equal(t, 1, factory.callCount()) | |||
| 1195 | + | require.Same(t, leaderResult.proxy, waiterResult.proxy, "all callers must receive the exact registered winner") | |||
| 1196 | + | d.mu.Lock() | |||
| 1197 | + | require.Same(t, leaderResult.proxy, d.sessions[key.ID()]) | |||
| 1198 | + | d.mu.Unlock() | |||
| 1199 | + | stopProxy(t, leaderResult.proxy) | |||
| 1200 | + | } | |||
| 1201 | + | ||||
| 1202 | + | func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) { | |||
| 1203 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 1204 | + | first := newProxyTestTransport() | |||
| 1205 | + | second := newProxyTestTransport() | |||
| 1206 | + | factory := newProxyConstructionFactory(first, second) | |||
| 1207 | + | d := newProxyTestDaemon(t, factory, nil) | |||
| 1208 | + | ||||
| 1209 | + | leader := openProxyForTest(d, context.Background(), key) | |||
| 1210 | + | require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial")) | |||
| 1211 | + | _ = requireProxyHello(t, first) | |||
| 1212 | + | ||||
| 1213 | + | base, cancel := context.WithCancel(context.Background()) | |||
| 1214 | + | observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})} | |||
| 1215 | + | waiter := openProxyForTest(d, observed, key) | |||
| 1216 | + | select { | |||
| 1217 | + | case <-observed.observed: | |||
| 1218 | + | case <-factory.secondAttempt: | |||
| 1219 | + | close(factory.releaseSecond) | |||
| 1220 | + | t.Fatal("cancelable waiter started another same-key handshake") | |||
| 1221 | + | } | |||
| 1222 | + | cancel() | |||
| 1223 | + | waiterResult := awaitTestValue(t, waiter, "canceled waiter did not finish") | |||
| 1224 | + | require.ErrorIs(t, waiterResult.err, context.Canceled) | |||
| 1225 | + | require.Nil(t, waiterResult.proxy) | |||
| 1226 | + | require.Equal(t, 1, factory.callCount()) | |||
| 1227 | + | ||||
| 1228 | + | first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 1229 | + | first.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 1230 | + | leaderResult := awaitTestValue(t, leader, "leader construction did not finish") | |||
| 1231 | + | require.NoError(t, leaderResult.err) | |||
| 1232 | + | stopProxy(t, leaderResult.proxy) | |||
| 1233 | + | } | |||
| 1234 | + | ||||
| 16 | type warmHookClock struct { | 1235 | type warmHookClock struct { | ||
| 17 | timers chan *signalTimer | 1236 | timers chan *signalTimer | ||
| 18 | 1237 | ||||
| @@ -290,3 +1509,115 @@ func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) { | |||||
| 290 | awaitTestCompletion(t, current.done, "current warm timer never ran to completion") | 1509 | awaitTestCompletion(t, current.done, "current warm timer never ran to completion") | ||
| 291 | require.False(t, proxyRegistered(d, p)) | 1510 | require.False(t, proxyRegistered(d, p)) | ||
| 292 | } | 1511 | } | ||
| 1512 | + | ||||
| 1513 | + | func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedClient, *closeTrackingTransport, *warmHookClock, attachmentRoleToken, *renderCoordinator) { | |||
| 1514 | + | t.Helper() | |||
| 1515 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 1516 | + | clock := &warmHookClock{timers: make(chan *signalTimer, 4)} | |||
| 1517 | + | d.clock = clock | |||
| 1518 | + | clientTransport := &closeTrackingTransport{} | |||
| 1519 | + | ac.replaceTransport(clientTransport) | |||
| 1520 | + | localCoordinator := d.attachCoordinator(local, nil, ac, true) | |||
| 1521 | + | localToken := attachmentToken(local, ac, clientTransport) | |||
| 1522 | + | localToken.lease = localCoordinator.attachmentLease(ac) | |||
| 1523 | + | proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"}) | |||
| 1524 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |||
| 1525 | + | source: local, target: proxy, next: ac, | |||
| 1526 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |||
| 1527 | + | expectedTransport: ac.transportSnapshot(), sourceToken: &localToken, | |||
| 1528 | + | action: "proxy detach fixture", ready: true, | |||
| 1529 | + | }) | |||
| 1530 | + | require.NoError(t, err) | |||
| 1531 | + | return d, proxy, ac, clientTransport, clock, transition.published, proxy.coordinator.Load() | |||
| 1532 | + | } | |||
| 1533 | + | ||||
| 1534 | + | func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) { | |||
| 1535 | + | tests := []struct { | |||
| 1536 | + | name string | |||
| 1537 | + | detach func(*Daemon, attachmentRoleToken, ports.Transport) bool | |||
| 1538 | + | }{ | |||
| 1539 | + | { | |||
| 1540 | + | name: "explicit", | |||
| 1541 | + | detach: func(d *Daemon, token attachmentRoleToken, _ ports.Transport) bool { | |||
| 1542 | + | effect, admitted := token.ac.beginRoleEffect(token) | |||
| 1543 | + | require.True(t, admitted) | |||
| 1544 | + | token.effect = effect | |||
| 1545 | + | return d.clientGoneForRole(token, true) | |||
| 1546 | + | }, | |||
| 1547 | + | }, | |||
| 1548 | + | { | |||
| 1549 | + | name: "send error", | |||
| 1550 | + | detach: func(d *Daemon, token attachmentRoleToken, failed ports.Transport) bool { | |||
| 1551 | + | d.detachOnRoleSendError(token, failed) | |||
| 1552 | + | return token.ac.currentAttachmentSession() == nil | |||
| 1553 | + | }, | |||
| 1554 | + | }, | |||
| 1555 | + | } | |||
| 1556 | + | for _, tt := range tests { | |||
| 1557 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1558 | + | d, proxy, ac, tr, clock, token, coordinator := newProxyRoleDetachFixture(t) | |||
| 1559 | + | require.True(t, tt.detach(d, token, tr)) | |||
| 1560 | + | require.Nil(t, ac.currentAttachmentSession()) | |||
| 1561 | + | require.True(t, tr.Closed()) | |||
| 1562 | + | require.Nil(t, coordinator.attachmentLease(ac), "coordinator must retire the detached client") | |||
| 1563 | + | var warmTimers []*signalTimer | |||
| 1564 | + | for len(warmTimers) == 0 { | |||
| 1565 | + | timer := awaitTestValue(t, clock.timers, "proxy detach did not arm a warm timer") | |||
| 1566 | + | if timer.duration == proxyWarmDuration { | |||
| 1567 | + | warmTimers = append(warmTimers, timer) | |||
| 1568 | + | } | |||
| 1569 | + | } | |||
| 1570 | + | select { | |||
| 1571 | + | case timer := <-clock.timers: | |||
| 1572 | + | if timer.duration == proxyWarmDuration { | |||
| 1573 | + | warmTimers = append(warmTimers, timer) | |||
| 1574 | + | } | |||
| 1575 | + | default: | |||
| 1576 | + | } | |||
| 1577 | + | require.Len(t, warmTimers, 1, "proxy detach must arm exactly one warm timer") | |||
| 1578 | + | warm, _ := proxyWarmToken(proxy) | |||
| 1579 | + | require.NotNil(t, warm) | |||
| 1580 | + | warm.stop() | |||
| 1581 | + | awaitTestCompletion(t, warm.done, "warm timer did not stop") | |||
| 1582 | + | }) | |||
| 1583 | + | } | |||
| 1584 | + | } | |||
| 1585 | + | ||||
| 1586 | + | func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) { | |||
| 1587 | + | tests := []struct { | |||
| 1588 | + | name string | |||
| 1589 | + | stale func(*attachedClient, *attachmentRoleToken) *closeTrackingTransport | |||
| 1590 | + | }{ | |||
| 1591 | + | { | |||
| 1592 | + | name: "transport", | |||
| 1593 | + | stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport { | |||
| 1594 | + | current := &closeTrackingTransport{} | |||
| 1595 | + | ac.replaceTransport(current) | |||
| 1596 | + | return current | |||
| 1597 | + | }, | |||
| 1598 | + | }, | |||
| 1599 | + | { | |||
| 1600 | + | name: "role generation", | |||
| 1601 | + | stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport { | |||
| 1602 | + | ac.roleGeneration.Add(1) | |||
| 1603 | + | return nil | |||
| 1604 | + | }, | |||
| 1605 | + | }, | |||
| 1606 | + | } | |||
| 1607 | + | for _, tt := range tests { | |||
| 1608 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1609 | + | d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t) | |||
| 1610 | + | current := tt.stale(ac, &token) | |||
| 1611 | + | d.detachOnRoleSendError(token, failed) | |||
| 1612 | + | require.Same(t, proxy, ac.currentAttachmentSession()) | |||
| 1613 | + | if current != nil { | |||
| 1614 | + | require.False(t, current.Closed(), "stale transport error closed the rebound client") | |||
| 1615 | + | } | |||
| 1616 | + | select { | |||
| 1617 | + | case <-clock.timers: | |||
| 1618 | + | t.Fatal("stale proxy detach armed a warm timer") | |||
| 1619 | + | default: | |||
| 1620 | + | } | |||
| 1621 | + | }) | |||
| 1622 | + | } | |||
| 1623 | + | } | |||
M internal/usecase/daemon/remote_picker.go +2 -2
| @@ -122,7 +122,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||||
| 122 | RemoteAvailability: remotePickerAvailability(status), | 122 | RemoteAvailability: remotePickerAvailability(status), | ||
| 123 | RemoteDetail: detail, | 123 | RemoteDetail: detail, | ||
| 124 | ConnectOnly: true, | 124 | ConnectOnly: true, | ||
| 125 | - | RemoteAttachReady: false, | 125 | + | RemoteAttachReady: status != remoteHostVersionMismatch, |
| 126 | CannotAcceptMoves: true, | 126 | CannotAcceptMoves: true, | ||
| 127 | } | 127 | } | ||
| 128 | } | 128 | } | ||
| @@ -155,7 +155,7 @@ func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker | |||||
| 155 | view.RemoteAvailability = picker.RemoteFresh | 155 | view.RemoteAvailability = picker.RemoteFresh | ||
| 156 | view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount)) | 156 | view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount)) | ||
| 157 | view.ConnectOnly = true | 157 | view.ConnectOnly = true | ||
| 158 | - | view.RemoteAttachReady = false | 158 | + | view.RemoteAttachReady = true |
| 159 | if snap.expired { | 159 | if snap.expired { | ||
| 160 | view.RemoteAvailability = picker.RemoteStale | 160 | view.RemoteAvailability = picker.RemoteStale | ||
| 161 | view.RemoteDetail = "expired" | 161 | view.RemoteDetail = "expired" | ||
M internal/usecase/daemon/remote_picker_test.go +12 -11
| @@ -759,7 +759,7 @@ func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing. | |||||
| 759 | owner.overlays.pickerMu.Unlock() | 759 | owner.overlays.pickerMu.Unlock() | ||
| 760 | require.True(t, ok) | 760 | require.True(t, ok) | ||
| 761 | require.Equal(t, &key, before.RemoteKey) | 761 | require.Equal(t, &key, before.RemoteKey) | ||
| 762 | - | require.False(t, beforeSelectable, "cache-derived remote row must stay gated until proxy activation lands") | 762 | + | require.True(t, beforeSelectable, "ownership activation makes a compatible cached row selectable") |
| 763 | 763 | ||||
| 764 | proxy := &proxySession{ | 764 | proxy := &proxySession{ | ||
| 765 | sessionCore: sessionCore{id: key.ID(), name: key.Display()}, | 765 | sessionCore: sessionCore{id: key.ID(), name: key.Display()}, | ||
| @@ -779,7 +779,7 @@ func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing. | |||||
| 779 | require.True(t, ok) | 779 | require.True(t, ok) | ||
| 780 | require.Equal(t, before.Session, after.Session) | 780 | require.Equal(t, before.Session, after.Session) | ||
| 781 | require.Equal(t, before.RemoteKey, after.RemoteKey) | 781 | require.Equal(t, before.RemoteKey, after.RemoteKey) | ||
| 782 | - | require.False(t, selectable, "cache-to-live upgrade must preserve the proxy activation gate") | 782 | + | require.True(t, selectable, "completed ownership must keep the upgraded proxy row selectable") |
| 783 | } | 783 | } | ||
| 784 | 784 | ||||
| 785 | func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) { | 785 | func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) { | ||
| @@ -951,14 +951,15 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | |||||
| 951 | ac.overlays.pickerMu.Unlock() | 951 | ac.overlays.pickerMu.Unlock() | ||
| 952 | } | 952 | } | ||
| 953 | 953 | ||||
| 954 | - | func TestRemoteRowsRemainNonSelectableDuringRefreshPhase(t *testing.T) { | 954 | + | func TestRemoteRowsBecomeSelectableOnlyAfterOwnershipCompletion(t *testing.T) { |
| 955 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 955 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | ||
| 956 | - | for _, availability := range []picker.RemoteAvailability{picker.RemoteCached, picker.RemoteFresh, picker.RemoteStale, picker.RemoteVersionMismatch} { | 956 | + | for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} { |
| 957 | - | model := picker.New([]picker.SessionView{{ | 957 | + | view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, status, time.Unix(1, 0)) |
| 958 | - | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: availability, | 958 | + | _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() |
| 959 | - | ConnectOnly: true, RemoteAttachReady: false, | 959 | + | require.True(t, selectable, "status %d is ready after ownership completion", status) |
| 960 | - | }}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | 960 | + | } |
| 961 | - | _, selectable := model.Selected() | 961 | + | mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{}) |
| 962 | - | require.False(t, selectable, "availability %d became remotely attachable in phase 5", availability) | 962 | + | require.False(t, mismatch.RemoteAttachReady) |
| 963 | - | } | 963 | + | _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() |
| 964 | + | require.False(t, selectable) | |||
| 964 | } | 965 | } | ||
M internal/usecase/daemon/render_coordinator_test.go +16 -9
| @@ -1084,10 +1084,11 @@ var producerFiles = []string{ | |||||
| 1084 | 1084 | ||||
| 1085 | func TestProducerInvalidations(t *testing.T) { | 1085 | func TestProducerInvalidations(t *testing.T) { | ||
| 1086 | cases := []struct { | 1086 | cases := []struct { | ||
| 1087 | - | file string | 1087 | + | file string |
| 1088 | - | name string | 1088 | + | name string |
| 1089 | - | tabs int | 1089 | + | tabs int |
| 1090 | - | run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) | 1090 | + | producer string |
| 1091 | + | run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) | |||
| 1091 | }{ | 1092 | }{ | ||
| 1092 | { | 1093 | { | ||
| 1093 | file: "attention.go", | 1094 | file: "attention.go", | ||
| @@ -1125,11 +1126,14 @@ func TestProducerInvalidations(t *testing.T) { | |||||
| 1125 | }, | 1126 | }, | ||
| 1126 | }, | 1127 | }, | ||
| 1127 | { | 1128 | { | ||
| 1128 | - | file: "input.go", | 1129 | + | file: "input.go", |
| 1129 | - | name: "switch tab key action", | 1130 | + | name: "proxied jump attention switches a local tab", |
| 1130 | - | tabs: 2, | 1131 | + | tabs: 2, |
| 1132 | + | producer: "input.go", | |||
| 1131 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | 1133 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | ||
| 1132 | - | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2) | 1134 | + | ac.proxied = true |
| 1135 | + | sess.tabs[1].attention = true | |||
| 1136 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil) | |||
| 1133 | require.Equal(t, 1, activeTabIndex(sess)) | 1137 | require.Equal(t, 1, activeTabIndex(sess)) | ||
| 1134 | }, | 1138 | }, | ||
| 1135 | }, | 1139 | }, | ||
| @@ -1222,7 +1226,10 @@ func TestProducerInvalidations(t *testing.T) { | |||||
| 1222 | 1226 | ||||
| 1223 | tc.run(t, d, sess, ac) | 1227 | tc.run(t, d, sess, ac) | ||
| 1224 | 1228 | ||||
| 1225 | - | awaitInvalidation(t, invs) | 1229 | + | inv := awaitInvalidation(t, invs) |
| 1230 | + | if tc.producer != "" { | |||
| 1231 | + | require.Equal(t, tc.producer, inv.producer) | |||
| 1232 | + | } | |||
| 1226 | requireNoInvalidation(t, invs) | 1233 | requireNoInvalidation(t, invs) | ||
| 1227 | requireNoCoordinatorOutputFrame(t, sends) | 1234 | requireNoCoordinatorOutputFrame(t, sends) | ||
| 1228 | }) | 1235 | }) | ||
M internal/usecase/daemon/session.go +11 -4
| @@ -156,20 +156,27 @@ type tab struct { | |||||
| 156 | // what it has actually seen). sendMu serialises the two senders — the render | 156 | // what it has actually seen). sendMu serialises the two senders — the render | ||
| 157 | // coordinator and the connection handler — so the transport's single-writer | 157 | // coordinator and the connection handler — so the transport's single-writer | ||
| 158 | 158 | ||||
| 159 | - | func (d *Daemon) touchMRU(sess *session) { | 159 | + | func (d *Daemon) touchMRU(entry attachmentSession) { |
| 160 | - | if d == nil || sess == nil { | 160 | + | if d == nil || entry == nil || entry.core() == nil { |
| 161 | return | 161 | return | ||
| 162 | } | 162 | } | ||
| 163 | + | core := entry.core() | |||
| 163 | seq := d.mruSeq.Add(1) | 164 | seq := d.mruSeq.Add(1) | ||
| 164 | for { | 165 | for { | ||
| 165 | - | old := sess.mruAt.Load() | 166 | + | old := core.mruAt.Load() |
| 166 | if old >= seq { | 167 | if old >= seq { | ||
| 167 | return | 168 | return | ||
| 168 | } | 169 | } | ||
| 169 | - | if sess.mruAt.CompareAndSwap(old, seq) { | 170 | + | if core.mruAt.CompareAndSwap(old, seq) { |
| 170 | break | 171 | break | ||
| 171 | } | 172 | } | ||
| 172 | } | 173 | } | ||
| 174 | + | // Proxy recency is live derived state only. Local named sessions retain the | |||
| 175 | + | // existing catalogue metadata publication semantics. | |||
| 176 | + | sess, ok := localSession(entry) | |||
| 177 | + | if !ok { | |||
| 178 | + | return | |||
| 179 | + | } | |||
| 173 | sess.mu.Lock() | 180 | sess.mu.Lock() | ||
| 174 | if !sess.ephemeral { | 181 | if !sess.ephemeral { | ||
| 175 | d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) | 182 | d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) | ||
M internal/usecase/daemon/session_meta_test.go +58 -19
| @@ -106,36 +106,76 @@ func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) { | |||||
| 106 | require.False(t, ac.sessionMetaSent) | 106 | require.False(t, ac.sessionMetaSent) | ||
| 107 | } | 107 | } | ||
| 108 | 108 | ||||
| 109 | - | func TestProxiedAttachedCommandRejectionPreservesRequestID(t *testing.T) { | 109 | + | func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) { |
| 110 | tests := []struct { | 110 | tests := []struct { | ||
| 111 | - | name string | 111 | + | name string |
| 112 | - | request ports.CommandRequest | 112 | + | request ports.CommandRequest |
| 113 | - | wantCode uint16 | 113 | + | ok bool |
| 114 | - | wantText string | 114 | + | code uint16 |
| 115 | + | text string | |||
| 115 | }{ | 116 | }{ | ||
| 116 | { | 117 | { | ||
| 117 | name: "valid attached command", | 118 | name: "valid attached command", | ||
| 118 | request: ports.CommandRequest{ | 119 | request: ports.CommandRequest{ | ||
| 119 | - | Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "new-tab", | 120 | + | Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "next-tab", |
| 120 | }, | 121 | }, | ||
| 121 | - | wantCode: ports.ErrNotScriptable, | 122 | + | ok: true, |
| 122 | - | wantText: "attached command relay is not enabled", | |||
| 123 | }, | 123 | }, | ||
| 124 | { | 124 | { | ||
| 125 | name: "target override is rejected", | 125 | name: "target override is rejected", | ||
| 126 | request: ports.CommandRequest{ | 126 | request: ports.CommandRequest{ | ||
| 127 | - | Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "new-tab", TargetSession: "other", | 127 | + | Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "next-tab", TargetSession: "other", |
| 128 | }, | 128 | }, | ||
| 129 | - | wantCode: ports.ErrNotScriptable, | 129 | + | code: ports.ErrInvalidCommandArgs, |
| 130 | - | wantText: "attached command relay is not enabled", | 130 | + | text: "attached commands cannot override their active session target", |
| 131 | }, | 131 | }, | ||
| 132 | { | 132 | { | ||
| 133 | name: "missing attached flag is rejected", | 133 | name: "missing attached flag is rejected", | ||
| 134 | request: ports.CommandRequest{ | 134 | request: ports.CommandRequest{ | ||
| 135 | - | Version: ports.ProtocolVersion, RequestID: 43, Slug: "new-tab", | 135 | + | Version: ports.ProtocolVersion, RequestID: 43, Slug: "next-tab", |
| 136 | }, | 136 | }, | ||
| 137 | - | wantCode: ports.ErrNotScriptable, | 137 | + | code: ports.ErrInvalidCommandArgs, |
| 138 | - | wantText: "attached command relay is not enabled", | 138 | + | text: "attached command flag is required", |
| 139 | + | }, | |||
| 140 | + | { | |||
| 141 | + | name: "unsupported version is rejected", | |||
| 142 | + | request: ports.CommandRequest{ | |||
| 143 | + | Version: ports.ProtocolVersion + 1, RequestID: 44, Attached: true, Slug: "next-tab", | |||
| 144 | + | }, | |||
| 145 | + | code: ports.ErrInvalidCommandArgs, | |||
| 146 | + | text: "unsupported command protocol version", | |||
| 147 | + | }, | |||
| 148 | + | { | |||
| 149 | + | name: "missing request id is rejected", | |||
| 150 | + | request: ports.CommandRequest{ | |||
| 151 | + | Version: ports.ProtocolVersion, Attached: true, Slug: "next-tab", | |||
| 152 | + | }, | |||
| 153 | + | code: ports.ErrInvalidCommandArgs, | |||
| 154 | + | text: "command request id is required", | |||
| 155 | + | }, | |||
| 156 | + | { | |||
| 157 | + | name: "self target is rejected", | |||
| 158 | + | request: ports.CommandRequest{ | |||
| 159 | + | Version: ports.ProtocolVersion, RequestID: 45, Attached: true, Self: true, Slug: "next-tab", | |||
| 160 | + | }, | |||
| 161 | + | code: ports.ErrInvalidCommandArgs, | |||
| 162 | + | text: "attached commands cannot target themselves", | |||
| 163 | + | }, | |||
| 164 | + | { | |||
| 165 | + | name: "unknown slug is rejected", | |||
| 166 | + | request: ports.CommandRequest{ | |||
| 167 | + | Version: ports.ProtocolVersion, RequestID: 46, Attached: true, Slug: "missing", | |||
| 168 | + | }, | |||
| 169 | + | code: ports.ErrUnknownCommand, | |||
| 170 | + | text: "unknown command: missing", | |||
| 171 | + | }, | |||
| 172 | + | { | |||
| 173 | + | name: "local detach is not scriptable", | |||
| 174 | + | request: ports.CommandRequest{ | |||
| 175 | + | Version: ports.ProtocolVersion, RequestID: 47, Attached: true, Slug: "detach", | |||
| 176 | + | }, | |||
| 177 | + | code: ports.ErrNotScriptable, | |||
| 178 | + | text: "detach is owned by the local proxy daemon", | |||
| 139 | }, | 179 | }, | ||
| 140 | } | 180 | } | ||
| 141 | for _, tt := range tests { | 181 | for _, tt := range tests { | ||
| @@ -149,14 +189,13 @@ func TestProxiedAttachedCommandRejectionPreservesRequestID(t *testing.T) { | |||||
| 149 | token := sess.attachmentToken(ac, ac.transport()) | 189 | token := sess.attachmentToken(ac, ac.transport()) | ||
| 150 | require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | 190 | require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | ||
| 151 | 191 | ||||
| 152 | - | reply := <-sends | 192 | + | reply := awaitFrame(t, sends, ports.MsgCommandResult) |
| 153 | - | require.Equal(t, ports.MsgCommandResult, reply.Type) | |||
| 154 | result, err := ports.UnmarshalCommandResult(reply.Payload) | 193 | result, err := ports.UnmarshalCommandResult(reply.Payload) | ||
| 155 | require.NoError(t, err) | 194 | require.NoError(t, err) | ||
| 156 | require.Equal(t, tt.request.RequestID, result.RequestID) | 195 | require.Equal(t, tt.request.RequestID, result.RequestID) | ||
| 157 | - | require.False(t, result.OK) | 196 | + | require.Equal(t, tt.ok, result.OK) |
| 158 | - | require.Equal(t, tt.wantCode, result.Code) | 197 | + | require.Equal(t, tt.code, result.Code) |
| 159 | - | require.Equal(t, tt.wantText, result.Text) | 198 | + | require.Equal(t, tt.text, result.Text) |
| 160 | }) | 199 | }) | ||
| 161 | } | 200 | } | ||
| 162 | } | 201 | } | ||
M internal/usecase/daemon/snatched_cleanup.go +9 -0
| @@ -1,5 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |||
| 4 | + | ||||
| 3 | // removeSnatchedAttachment removes exactly one role generation and transport | 5 | // removeSnatchedAttachment removes exactly one role generation and transport | ||
| 4 | // from session routing. It never touches the active attachment or closes a | 6 | // from session routing. It never touches the active attachment or closes a | ||
| 5 | // transport, allowing callers that already interrupted a blocked send to avoid | 7 | // transport, allowing callers that already interrupted a blocked send to avoid | ||
| @@ -176,6 +178,13 @@ func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool { | |||||
| 176 | // captured transport; an idle healthy snatched transport remains connected and | 178 | // captured transport; an idle healthy snatched transport remains connected and | ||
| 177 | // receives a dependency-free reset panel. | 179 | // receives a dependency-free reset panel. | ||
| 178 | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | 180 | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | ||
| 181 | + | if terminal := result.terminalDisplaced; terminal.ac != nil && terminal.transport.transport != nil { | |||
| 182 | + | terminal.ac.clearSnatchedInput() | |||
| 183 | + | d.unregisterPreview(terminal.ac) | |||
| 184 | + | terminal.ac.clearPreviousSession() | |||
| 185 | + | terminal.ac.clearCaptureFrames() | |||
| 186 | + | d.notifyDetachedSnapshotAsync(terminal, ports.ReasonReplaced) | |||
| 187 | + | } | |||
| 179 | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | 188 | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | ||
| 180 | blockedRender := result.displacedInterrupted | 189 | blockedRender := result.displacedInterrupted | ||
| 181 | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | 190 | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | ||
M internal/usecase/keys/router.go +12 -6
| @@ -48,10 +48,11 @@ const ( | |||||
| 48 | ) | 48 | ) | ||
| 49 | 49 | ||||
| 50 | // Handler receives router outputs. Forward is called only for bytes that should | 50 | // Handler receives router outputs. Forward is called only for bytes that should | ||
| 51 | - | // reach the PTY; Action is called for intercepted bindings. | 51 | + | // reach the PTY; Action receives intercepted bindings and the exact consumed |
| 52 | + | // terminal bytes. Both byte slices are owned copies. | |||
| 52 | type Handler interface { | 53 | type Handler interface { | ||
| 53 | Forward([]byte) | 54 | Forward([]byte) | ||
| 54 | - | Action(Action) | 55 | + | Action(Action, []byte) |
| 55 | } | 56 | } | ||
| 56 | 57 | ||||
| 57 | // Router implements vev's Alt/ESC input routing. It retains a trailing ESC for | 58 | // Router implements vev's Alt/ESC input routing. It retains a trailing ESC for | ||
| @@ -151,7 +152,7 @@ func (r *Router) route(data []byte, h Handler) { | |||||
| 151 | } | 152 | } | ||
| 152 | if action, size, ok, partial := r.altArrowCSI(remaining); ok { | 153 | if action, size, ok, partial := r.altArrowCSI(remaining); ok { | ||
| 153 | flush() | 154 | flush() | ||
| 154 | - | h.Action(action) | 155 | + | r.action(action, data[i:i+1+size], h) |
| 155 | i += 1 + size | 156 | i += 1 + size | ||
| 156 | continue | 157 | continue | ||
| 157 | } else if partial { | 158 | } else if partial { | ||
| @@ -167,7 +168,7 @@ func (r *Router) route(data []byte, h Handler) { | |||||
| 167 | } | 168 | } | ||
| 168 | if action, size, ok := r.binding(remaining); ok { | 169 | if action, size, ok := r.binding(remaining); ok { | ||
| 169 | flush() | 170 | flush() | ||
| 170 | - | h.Action(action) | 171 | + | r.action(action, data[i:i+1+size], h) |
| 171 | i += 1 + size | 172 | i += 1 + size | ||
| 172 | continue | 173 | continue | ||
| 173 | } | 174 | } | ||
| @@ -184,7 +185,7 @@ func (r *Router) route(data []byte, h Handler) { | |||||
| 184 | 185 | ||||
| 185 | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int { | 186 | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int { | ||
| 186 | if action, size, ok, partial := r.altArrowCSI(data); ok { | 187 | if action, size, ok, partial := r.altArrowCSI(data); ok { | ||
| 187 | - | h.Action(action) | 188 | + | r.action(action, append([]byte{ESC}, data[:size]...), h) |
| 188 | return size | 189 | return size | ||
| 189 | } else if partial { | 190 | } else if partial { | ||
| 190 | r.retainESC(h, data) | 191 | r.retainESC(h, data) | ||
| @@ -192,7 +193,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) | |||||
| 192 | } | 193 | } | ||
| 193 | next := data[0] | 194 | next := data[0] | ||
| 194 | if action, size, ok := r.binding(data); ok { | 195 | if action, size, ok := r.binding(data); ok { | ||
| 195 | - | h.Action(action) | 196 | + | r.action(action, append([]byte{ESC}, data[:size]...), h) |
| 196 | return size | 197 | return size | ||
| 197 | } | 198 | } | ||
| 198 | if partialUTF8Rune(data) { | 199 | if partialUTF8Rune(data) { | ||
| @@ -279,6 +280,11 @@ func (r *Router) forward(data []byte, h Handler) { | |||||
| 279 | h.Forward(cp) | 280 | h.Forward(cp) | ||
| 280 | } | 281 | } | ||
| 281 | 282 | ||||
| 283 | + | func (r *Router) action(action Action, raw []byte, h Handler) { | |||
| 284 | + | cp := append([]byte(nil), raw...) | |||
| 285 | + | h.Action(action, cp) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 282 | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | 288 | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | ||
| 283 | 289 | ||||
| 284 | func (r *Router) currentBindings() *Bindings { | 290 | func (r *Router) currentBindings() *Bindings { | ||
M internal/usecase/keys/router_test.go +41 -4
| @@ -33,9 +33,10 @@ func (t *fakeTimer) Stop() bool { t.stopped = true; return true | |||||
| 33 | func (t *fakeTimer) fire() { t.ch <- time.Time{} } | 33 | func (t *fakeTimer) fire() { t.ch <- time.Time{} } | ||
| 34 | 34 | ||||
| 35 | type captureHandler struct { | 35 | type captureHandler struct { | ||
| 36 | - | forwards [][]byte | 36 | + | forwards [][]byte |
| 37 | - | actions []Action | 37 | + | actions []Action |
| 38 | - | notify chan struct{} | 38 | + | actionRaw [][]byte |
| 39 | + | notify chan struct{} | |||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 41 | func (h *captureHandler) Forward(data []byte) { | 42 | func (h *captureHandler) Forward(data []byte) { | ||
| @@ -44,7 +45,43 @@ func (h *captureHandler) Forward(data []byte) { | |||||
| 44 | h.notify <- struct{}{} | 45 | h.notify <- struct{}{} | ||
| 45 | } | 46 | } | ||
| 46 | } | 47 | } | ||
| 47 | - | func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) } | 48 | + | func (h *captureHandler) Action(a Action, raw []byte) { |
| 49 | + | h.actions = append(h.actions, a) | |||
| 50 | + | h.actionRaw = append(h.actionRaw, append([]byte(nil), raw...)) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestRouterRawActionPreservesExactConsumedBytes(t *testing.T) { | |||
| 54 | + | custom := DefaultBindings().clone() | |||
| 55 | + | custom.bind(ActionEqualizePanes, KeySpec{altRunes: []rune{'ø'}}) | |||
| 56 | + | var bindings atomic.Pointer[Bindings] | |||
| 57 | + | bindings.Store(custom) | |||
| 58 | + | ||||
| 59 | + | tests := []struct { | |||
| 60 | + | name string | |||
| 61 | + | reads [][]byte | |||
| 62 | + | action Action | |||
| 63 | + | raw []byte | |||
| 64 | + | }{ | |||
| 65 | + | {name: "single frame", reads: [][]byte{{ESC, 'j'}}, action: ActionFocusPaneDown, raw: []byte{ESC, 'j'}}, | |||
| 66 | + | {name: "delayed escape", reads: [][]byte{{ESC}, {'1'}}, action: ActionSwitchTab1, raw: []byte{ESC, '1'}}, | |||
| 67 | + | {name: "split UTF-8 custom binding", reads: [][]byte{{ESC, 0xc3}, {0xb8}}, action: ActionEqualizePanes, raw: []byte{ESC, 0xc3, 0xb8}}, | |||
| 68 | + | {name: "split Alt arrow", reads: [][]byte{{ESC, '[', '1'}, {';', '9', 'D'}}, action: ActionFocusPaneLeft, raw: []byte{ESC, '[', '1', ';', '9', 'D'}}, | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | for _, tt := range tests { | |||
| 72 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 73 | + | clk := &fakeClock{} | |||
| 74 | + | h := &captureHandler{} | |||
| 75 | + | router := NewRouter(clk, h, &bindings) | |||
| 76 | + | for _, read := range tt.reads { | |||
| 77 | + | router.Route(read) | |||
| 78 | + | } | |||
| 79 | + | require.Equal(t, []Action{tt.action}, h.actions) | |||
| 80 | + | require.Equal(t, [][]byte{tt.raw}, h.actionRaw) | |||
| 81 | + | require.Empty(t, h.forwards) | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 48 | 85 | ||||
| 49 | func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) { | 86 | func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) { | ||
| 50 | cases := []struct { | 87 | cases := []struct { | ||