detached 8f53268

refactor(daemon): decompose move orchestration

commit
8f53268431b50abeaf159a42a24c6d68568a4195
author
brice · · 2026-07-28 12:41 UTC
parent
5c07e9a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +938 -918
M internal/usecase/daemon/client.go +0 -321
@@ -657,266 +657,6 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
657657 }
658658 }
659659
660-// runConnLoop is the per-connection input router: it pumps client messages
661-// until detach, EOF, or a transport error. Role is resolved after every Recv so
662-// a transport displaced while blocked in Recv immediately adopts its restricted
663-// snatched routing rather than executing one stale active frame.
664-func (d *Daemon) runConnLoop(ac *attachedClient) {
665- tr := ac.transport()
666- if tr == nil {
667- return
668- }
669- for {
670- if !ac.currentTransportIs(tr) {
671- return
672- }
673- sess := ac.currentSession()
674- if sess == nil {
675- return
676- }
677- f, err := tr.Recv()
678- if err != nil {
679- token := sess.attachmentToken(ac, tr)
680- if token.role == attachmentSnatched {
681- d.parkOrDropSnatchedAttachment(token)
682- } else {
683- d.clientGone(sess, ac, tr, false)
684- }
685- return
686- }
687- if !ac.currentTransportIs(tr) {
688- return
689- }
690- sess = ac.currentSession()
691- if sess == nil {
692- return
693- }
694- token := sess.attachmentToken(ac, tr)
695- switch token.role {
696- case attachmentActive:
697- if d.handleActiveClientFrame(token, f) {
698- return
699- }
700- case attachmentSnatched:
701- if d.handleSnatchedClientFrame(token, f) {
702- return
703- }
704- default:
705- return
706- }
707- }
708-}
709-
710-// handleActiveClientFrame owns the ordinary attached-client protocol. Keeping
711-// it separate makes the restricted snatched protocol an explicit allow-list.
712-func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {
713- if !token.activeEffect() {
714- return false
715- }
716- if d.afterActiveFrameDispatch != nil {
717- d.afterActiveFrameDispatch(token)
718- }
719- ticket, admitted := token.ac.beginRoleEffect(token)
720- if !admitted {
721- return false
722- }
723- token.effect = ticket
724- defer token.endRoleEffect()
725- if d.afterRoleEffectAdmitted != nil {
726- d.afterRoleEffectAdmitted(token)
727- }
728- switch f.Type {
729- case ports.MsgInput:
730- if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
731- d.handleSequencedInputForRole(token, in.InputSeq, in.Data)
732- }
733- case ports.MsgResize:
734- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
735- d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false)
736- }
737- case ports.MsgTheme:
738- if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
739- d.applyThemeForRole(token, th)
740- }
741- case ports.MsgImagePush:
742- if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
743- d.handleSequencedImagePushForRole(token, ip.InputSeq, ip)
744- }
745- case ports.MsgClientNotice:
746- if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
747- d.handleClientNoticeForRole(token, notice)
748- } else {
749- d.log.Warn("malformed client notice", "err", derr)
750- }
751- case ports.MsgDetach:
752- if token.activeEffect() {
753- d.clientGoneForRole(token, true)
754- return true
755- }
756- case ports.MsgAck:
757- if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
758- d.ackActiveOutput(token, ack.AckedStateNum)
759- }
760- case ports.MsgPing:
761- if err := token.sendActiveControl(framePong()); err != nil {
762- token.endRoleEffect()
763- d.detachOnSendError(token.sess, token.ac, token.transport.transport)
764- return true
765- }
766- default:
767- // Unknown/out-of-band client messages are ignored so a newer
768- // client can add message types without breaking an older daemon.
769- }
770- return false
771-}
772-
773-func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
774- ac := token.ac
775- if token.effect == nil || token.effect.ended.Load() {
776- return false
777- }
778- ac.sendMu.Lock()
779- ac.output.ack(state)
780- ac.sendMu.Unlock()
781- if rc := token.sess.renderCoordinator(); rc != nil {
782- rc.notifyAckForLease(token.lease)
783- }
784- return true
785-}
786-
787-// handleSnatchedClientFrame is intentionally an allow-list. Strict resume and
788-// quit actions are introduced separately; until then ordinary input is ignored.
789-func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool {
790- if !token.current() {
791- return false
792- }
793- ticket, admitted := token.ac.beginRoleEffect(token)
794- if !admitted {
795- return false
796- }
797- token.effect = ticket
798- defer token.endRoleEffect()
799- if d.afterRoleEffectAdmitted != nil {
800- d.afterRoleEffectAdmitted(token)
801- }
802- switch f.Type {
803- case ports.MsgInput:
804- if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) {
805- return true
806- }
807- case ports.MsgResize:
808- if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {
809- ac := token.ac
810- ac.sendMu.Lock()
811- if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
812- ac.sendMu.Unlock()
813- return false
814- }
815- ac.size = resize.Size
816- ac.sendMu.Unlock()
817- if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
818- token.endRoleEffect()
819- d.parkOrDropSnatchedAttachment(token)
820- return true
821- }
822- }
823- case ports.MsgTheme:
824- if theme, err := ports.UnmarshalTheme(f.Payload); err == nil {
825- ac := token.ac
826- ac.sendMu.Lock()
827- if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
828- ac.sendMu.Unlock()
829- return false
830- }
831- clientTheme := themeFromMessage(theme)
832- ac.setClientTheme(clientTheme)
833- ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
834- ac.sendMu.Unlock()
835- if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
836- token.endRoleEffect()
837- d.parkOrDropSnatchedAttachment(token)
838- return true
839- }
840- }
841- case ports.MsgAck:
842- if ack, err := ports.UnmarshalAck(f.Payload); err == nil {
843- token.ac.ackOutputState(ack.AckedStateNum)
844- }
845- case ports.MsgPing:
846- if err := d.sendSnatchedControl(token, framePong()); err != nil {
847- token.endRoleEffect()
848- d.parkOrDropSnatchedAttachment(token)
849- return true
850- }
851- case ports.MsgDetach:
852- _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach))
853- token.endRoleEffect()
854- d.dropSnatchedAttachment(token)
855- return true
856- }
857- return false
858-}
859-
860-// clientGone detaches ac if it is still the session's current client. The
861-// session remains registered and headless after the client is gone.
862-func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
863- if failed != nil && !ac.currentTransportIs(failed) {
864- return // stale connection loop; a newer transport owns this client
865- }
866- if !d.detachIfCurrent(sess, ac) {
867- return // already displaced by a newer client; nothing to do
868- }
869- d.finishClientGone(sess, ac, failed, explicit)
870-}
871-
872-func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
873- if token.effect == nil {
874- return false
875- }
876- token.effect.bindActionEnd(d, "detach")
877- token.effect.End()
878- if !d.detachIfRoleCurrent(token) {
879- return false
880- }
881- d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)
882- return true
883-}
884-
885-func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
886- if rc := sess.renderCoordinator(); rc != nil {
887- rc.noteDetach(ac)
888- }
889- d.unregisterPreview(ac)
890- sess.mu.Lock()
891- ephemeral := sess.ephemeral
892- sess.mu.Unlock()
893- if !ephemeral {
894- d.refreshSessionCwd(sess)
895- }
896- d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
897- oldTr := failed
898- if oldTr == nil {
899- oldTr = ac.transport()
900- }
901- if !explicit && d.parkAttachment(sess, ac) {
902- _ = ac.closeCapturedTransport(oldTr)
903- d.log.Info("client parked", "session", sess.name)
904- return
905- }
906-
907- d.resetScreenDefaultColors(sess)
908- ac.clearPreviousSession()
909- if explicit {
910- // Synchronous so the ack is delivered before the transport closes
911- // (the client is actively awaiting it), but deadline-bounded so a
912- // wedged client cannot pin this conn handler and hang Serve's
913- // connWg.Wait.
914- d.boundedSend(ac, frameDetached(ports.ReasonDetach))
915- }
916- _ = ac.closeCapturedTransport(oldTr)
917- d.log.Info("client detached", "session", sess.name, "explicit", explicit)
918-}
919-
920660 // detachIfCurrent clears the client iff ac is the current one, reporting
921661
922662 func themeFromMessage(msg ports.Theme) themeui.Theme {
@@ -1060,67 +800,6 @@ func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domai
1060800 return d.requestTransactionalResize(sess, ac, sz, true)
1061801 }
1062802
1063-// detachOnSendError drops a client whose transport failed, leaving the session
1064-// registered and headless.
1065-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
1066- if failed != nil && !ac.currentTransportIs(failed) {
1067- return
1068- }
1069- if d.detachIfCurrent(sess, ac) {
1070- d.finishSendErrorDetach(sess, ac, failed)
1071- }
1072-}
1073-
1074-func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
1075- d.detachOnRoleSendErrorUntil(token, failed, nil)
1076-}
1077-
1078-func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
1079- if failed != token.transport.transport {
1080- return
1081- }
1082- if d.detachIfRoleCurrentUntil(token, done) {
1083- d.finishSendErrorDetach(token.sess, token.ac, failed)
1084- }
1085-}
1086-
1087-// reserveRoleSendErrorCleanup accounts for cleanup before End releases the
1088-// role gate. This closes the WaitGroup Add/Wait race with terminal teardown;
1089-// the returned launch function must be invoked immediately after ticket End.
1090-func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() {
1091- d.attachmentCleanupWg.Add(1)
1092- return func() {
1093- go func() {
1094- defer d.attachmentCleanupWg.Done()
1095- if d.afterRoleSendErrorCleanup != nil {
1096- defer d.afterRoleSendErrorCleanup()
1097- }
1098- if d.beforeRoleSendErrorCleanup != nil {
1099- d.beforeRoleSendErrorCleanup(token)
1100- }
1101- deadline := newRoleEffectDrainDeadline(d.clock)
1102- defer deadline.stop()
1103- d.detachOnRoleSendErrorUntil(token, failed, deadline.Done)
1104- }()
1105- }
1106-}
1107-
1108-func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {
1109- if rc := sess.renderCoordinator(); rc != nil {
1110- rc.noteDetach(ac)
1111- }
1112- d.unregisterPreview(ac)
1113- if d.parkAttachment(sess, ac) {
1114- _ = ac.closeCapturedTransport(failed)
1115- d.log.Warn("parked client after send error", "session", sess.name)
1116- return
1117- }
1118- d.resetScreenDefaultColors(sess)
1119- ac.clearPreviousSession()
1120- _ = ac.closeCapturedTransport(failed)
1121- d.log.Warn("detached client after send error", "session", sess.name)
1122-}
1123-
1124803 // killSession removes a session and tears down its resources. It is
1125804 // idempotent: only the caller that wins the registry delete acts. When the
1126805 // registry empties it marks the daemon closing (atomically with the
@@ -657,266 +657,6 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
657 }657 }
658 }658 }
659 659
660-// runConnLoop is the per-connection input router: it pumps client messages
661-// until detach, EOF, or a transport error. Role is resolved after every Recv so
662-// a transport displaced while blocked in Recv immediately adopts its restricted
663-// snatched routing rather than executing one stale active frame.
664-func (d *Daemon) runConnLoop(ac *attachedClient) {
665- tr := ac.transport()
666- if tr == nil {
667- return
668- }
669- for {
670- if !ac.currentTransportIs(tr) {
671- return
672- }
673- sess := ac.currentSession()
674- if sess == nil {
675- return
676- }
677- f, err := tr.Recv()
678- if err != nil {
679- token := sess.attachmentToken(ac, tr)
680- if token.role == attachmentSnatched {
681- d.parkOrDropSnatchedAttachment(token)
682- } else {
683- d.clientGone(sess, ac, tr, false)
684- }
685- return
686- }
687- if !ac.currentTransportIs(tr) {
688- return
689- }
690- sess = ac.currentSession()
691- if sess == nil {
692- return
693- }
694- token := sess.attachmentToken(ac, tr)
695- switch token.role {
696- case attachmentActive:
697- if d.handleActiveClientFrame(token, f) {
698- return
699- }
700- case attachmentSnatched:
701- if d.handleSnatchedClientFrame(token, f) {
702- return
703- }
704- default:
705- return
706- }
707- }
708-}
709-
710-// handleActiveClientFrame owns the ordinary attached-client protocol. Keeping
711-// it separate makes the restricted snatched protocol an explicit allow-list.
712-func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {
713- if !token.activeEffect() {
714- return false
715- }
716- if d.afterActiveFrameDispatch != nil {
717- d.afterActiveFrameDispatch(token)
718- }
719- ticket, admitted := token.ac.beginRoleEffect(token)
720- if !admitted {
721- return false
722- }
723- token.effect = ticket
724- defer token.endRoleEffect()
725- if d.afterRoleEffectAdmitted != nil {
726- d.afterRoleEffectAdmitted(token)
727- }
728- switch f.Type {
729- case ports.MsgInput:
730- if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
731- d.handleSequencedInputForRole(token, in.InputSeq, in.Data)
732- }
733- case ports.MsgResize:
734- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
735- d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false)
736- }
737- case ports.MsgTheme:
738- if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
739- d.applyThemeForRole(token, th)
740- }
741- case ports.MsgImagePush:
742- if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
743- d.handleSequencedImagePushForRole(token, ip.InputSeq, ip)
744- }
745- case ports.MsgClientNotice:
746- if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
747- d.handleClientNoticeForRole(token, notice)
748- } else {
749- d.log.Warn("malformed client notice", "err", derr)
750- }
751- case ports.MsgDetach:
752- if token.activeEffect() {
753- d.clientGoneForRole(token, true)
754- return true
755- }
756- case ports.MsgAck:
757- if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
758- d.ackActiveOutput(token, ack.AckedStateNum)
759- }
760- case ports.MsgPing:
761- if err := token.sendActiveControl(framePong()); err != nil {
762- token.endRoleEffect()
763- d.detachOnSendError(token.sess, token.ac, token.transport.transport)
764- return true
765- }
766- default:
767- // Unknown/out-of-band client messages are ignored so a newer
768- // client can add message types without breaking an older daemon.
769- }
770- return false
771-}
772-
773-func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
774- ac := token.ac
775- if token.effect == nil || token.effect.ended.Load() {
776- return false
777- }
778- ac.sendMu.Lock()
779- ac.output.ack(state)
780- ac.sendMu.Unlock()
781- if rc := token.sess.renderCoordinator(); rc != nil {
782- rc.notifyAckForLease(token.lease)
783- }
784- return true
785-}
786-
787-// handleSnatchedClientFrame is intentionally an allow-list. Strict resume and
788-// quit actions are introduced separately; until then ordinary input is ignored.
789-func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool {
790- if !token.current() {
791- return false
792- }
793- ticket, admitted := token.ac.beginRoleEffect(token)
794- if !admitted {
795- return false
796- }
797- token.effect = ticket
798- defer token.endRoleEffect()
799- if d.afterRoleEffectAdmitted != nil {
800- d.afterRoleEffectAdmitted(token)
801- }
802- switch f.Type {
803- case ports.MsgInput:
804- if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) {
805- return true
806- }
807- case ports.MsgResize:
808- if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {
809- ac := token.ac
810- ac.sendMu.Lock()
811- if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
812- ac.sendMu.Unlock()
813- return false
814- }
815- ac.size = resize.Size
816- ac.sendMu.Unlock()
817- if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
818- token.endRoleEffect()
819- d.parkOrDropSnatchedAttachment(token)
820- return true
821- }
822- }
823- case ports.MsgTheme:
824- if theme, err := ports.UnmarshalTheme(f.Payload); err == nil {
825- ac := token.ac
826- ac.sendMu.Lock()
827- if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
828- ac.sendMu.Unlock()
829- return false
830- }
831- clientTheme := themeFromMessage(theme)
832- ac.setClientTheme(clientTheme)
833- ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
834- ac.sendMu.Unlock()
835- if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
836- token.endRoleEffect()
837- d.parkOrDropSnatchedAttachment(token)
838- return true
839- }
840- }
841- case ports.MsgAck:
842- if ack, err := ports.UnmarshalAck(f.Payload); err == nil {
843- token.ac.ackOutputState(ack.AckedStateNum)
844- }
845- case ports.MsgPing:
846- if err := d.sendSnatchedControl(token, framePong()); err != nil {
847- token.endRoleEffect()
848- d.parkOrDropSnatchedAttachment(token)
849- return true
850- }
851- case ports.MsgDetach:
852- _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach))
853- token.endRoleEffect()
854- d.dropSnatchedAttachment(token)
855- return true
856- }
857- return false
858-}
859-
860-// clientGone detaches ac if it is still the session's current client. The
861-// session remains registered and headless after the client is gone.
862-func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
863- if failed != nil && !ac.currentTransportIs(failed) {
864- return // stale connection loop; a newer transport owns this client
865- }
866- if !d.detachIfCurrent(sess, ac) {
867- return // already displaced by a newer client; nothing to do
868- }
869- d.finishClientGone(sess, ac, failed, explicit)
870-}
871-
872-func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
873- if token.effect == nil {
874- return false
875- }
876- token.effect.bindActionEnd(d, "detach")
877- token.effect.End()
878- if !d.detachIfRoleCurrent(token) {
879- return false
880- }
881- d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)
882- return true
883-}
884-
885-func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
886- if rc := sess.renderCoordinator(); rc != nil {
887- rc.noteDetach(ac)
888- }
889- d.unregisterPreview(ac)
890- sess.mu.Lock()
891- ephemeral := sess.ephemeral
892- sess.mu.Unlock()
893- if !ephemeral {
894- d.refreshSessionCwd(sess)
895- }
896- d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
897- oldTr := failed
898- if oldTr == nil {
899- oldTr = ac.transport()
900- }
901- if !explicit && d.parkAttachment(sess, ac) {
902- _ = ac.closeCapturedTransport(oldTr)
903- d.log.Info("client parked", "session", sess.name)
904- return
905- }
906-
907- d.resetScreenDefaultColors(sess)
908- ac.clearPreviousSession()
909- if explicit {
910- // Synchronous so the ack is delivered before the transport closes
911- // (the client is actively awaiting it), but deadline-bounded so a
912- // wedged client cannot pin this conn handler and hang Serve's
913- // connWg.Wait.
914- d.boundedSend(ac, frameDetached(ports.ReasonDetach))
915- }
916- _ = ac.closeCapturedTransport(oldTr)
917- d.log.Info("client detached", "session", sess.name, "explicit", explicit)
918-}
919-
920 // detachIfCurrent clears the client iff ac is the current one, reporting660 // detachIfCurrent clears the client iff ac is the current one, reporting
921 661
922 func themeFromMessage(msg ports.Theme) themeui.Theme {662 func themeFromMessage(msg ports.Theme) themeui.Theme {
@@ -1060,67 +800,6 @@ func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domai
1060 return d.requestTransactionalResize(sess, ac, sz, true)800 return d.requestTransactionalResize(sess, ac, sz, true)
1061 }801 }
1062 802
1063-// detachOnSendError drops a client whose transport failed, leaving the session
1064-// registered and headless.
1065-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
1066- if failed != nil && !ac.currentTransportIs(failed) {
1067- return
1068- }
1069- if d.detachIfCurrent(sess, ac) {
1070- d.finishSendErrorDetach(sess, ac, failed)
1071- }
1072-}
1073-
1074-func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
1075- d.detachOnRoleSendErrorUntil(token, failed, nil)
1076-}
1077-
1078-func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
1079- if failed != token.transport.transport {
1080- return
1081- }
1082- if d.detachIfRoleCurrentUntil(token, done) {
1083- d.finishSendErrorDetach(token.sess, token.ac, failed)
1084- }
1085-}
1086-
1087-// reserveRoleSendErrorCleanup accounts for cleanup before End releases the
1088-// role gate. This closes the WaitGroup Add/Wait race with terminal teardown;
1089-// the returned launch function must be invoked immediately after ticket End.
1090-func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() {
1091- d.attachmentCleanupWg.Add(1)
1092- return func() {
1093- go func() {
1094- defer d.attachmentCleanupWg.Done()
1095- if d.afterRoleSendErrorCleanup != nil {
1096- defer d.afterRoleSendErrorCleanup()
1097- }
1098- if d.beforeRoleSendErrorCleanup != nil {
1099- d.beforeRoleSendErrorCleanup(token)
1100- }
1101- deadline := newRoleEffectDrainDeadline(d.clock)
1102- defer deadline.stop()
1103- d.detachOnRoleSendErrorUntil(token, failed, deadline.Done)
1104- }()
1105- }
1106-}
1107-
1108-func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {
1109- if rc := sess.renderCoordinator(); rc != nil {
1110- rc.noteDetach(ac)
1111- }
1112- d.unregisterPreview(ac)
1113- if d.parkAttachment(sess, ac) {
1114- _ = ac.closeCapturedTransport(failed)
1115- d.log.Warn("parked client after send error", "session", sess.name)
1116- return
1117- }
1118- d.resetScreenDefaultColors(sess)
1119- ac.clearPreviousSession()
1120- _ = ac.closeCapturedTransport(failed)
1121- d.log.Warn("detached client after send error", "session", sess.name)
1122-}
1123-
1124 // killSession removes a session and tears down its resources. It is803 // killSession removes a session and tears down its resources. It is
1125 // idempotent: only the caller that wins the registry delete acts. When the804 // idempotent: only the caller that wins the registry delete acts. When the
1126 // registry empties it marks the daemon closing (atomically with the805 // registry empties it marks the daemon closing (atomically with the
A internal/usecase/daemon/client_frame_routing.go +203 -0
@@ -0,0 +1,203 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+// 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
7+// a transport displaced while blocked in Recv immediately adopts its restricted
8+// snatched routing rather than executing one stale active frame.
9+func (d *Daemon) runConnLoop(ac *attachedClient) {
10+ tr := ac.transport()
11+ if tr == nil {
12+ return
13+ }
14+ for {
15+ if !ac.currentTransportIs(tr) {
16+ return
17+ }
18+ sess := ac.currentSession()
19+ if sess == nil {
20+ return
21+ }
22+ f, err := tr.Recv()
23+ if err != nil {
24+ token := sess.attachmentToken(ac, tr)
25+ if token.role == attachmentSnatched {
26+ d.parkOrDropSnatchedAttachment(token)
27+ } else {
28+ d.clientGone(sess, ac, tr, false)
29+ }
30+ return
31+ }
32+ if !ac.currentTransportIs(tr) {
33+ return
34+ }
35+ sess = ac.currentSession()
36+ if sess == nil {
37+ return
38+ }
39+ token := sess.attachmentToken(ac, tr)
40+ switch token.role {
41+ case attachmentActive:
42+ if d.handleActiveClientFrame(token, f) {
43+ return
44+ }
45+ case attachmentSnatched:
46+ if d.handleSnatchedClientFrame(token, f) {
47+ return
48+ }
49+ default:
50+ return
51+ }
52+ }
53+}
54+
55+// handleActiveClientFrame owns the ordinary attached-client protocol. Keeping
56+// it separate makes the restricted snatched protocol an explicit allow-list.
57+func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {
58+ if !token.activeEffect() {
59+ return false
60+ }
61+ if d.afterActiveFrameDispatch != nil {
62+ d.afterActiveFrameDispatch(token)
63+ }
64+ ticket, admitted := token.ac.beginRoleEffect(token)
65+ if !admitted {
66+ return false
67+ }
68+ token.effect = ticket
69+ defer token.endRoleEffect()
70+ if d.afterRoleEffectAdmitted != nil {
71+ d.afterRoleEffectAdmitted(token)
72+ }
73+ switch f.Type {
74+ case ports.MsgInput:
75+ if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
76+ d.handleSequencedInputForRole(token, in.InputSeq, in.Data)
77+ }
78+ case ports.MsgResize:
79+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
80+ d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false)
81+ }
82+ case ports.MsgTheme:
83+ if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
84+ d.applyThemeForRole(token, th)
85+ }
86+ case ports.MsgImagePush:
87+ if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
88+ d.handleSequencedImagePushForRole(token, ip.InputSeq, ip)
89+ }
90+ case ports.MsgClientNotice:
91+ if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
92+ d.handleClientNoticeForRole(token, notice)
93+ } else {
94+ d.log.Warn("malformed client notice", "err", derr)
95+ }
96+ case ports.MsgDetach:
97+ if token.activeEffect() {
98+ d.clientGoneForRole(token, true)
99+ return true
100+ }
101+ case ports.MsgAck:
102+ if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
103+ d.ackActiveOutput(token, ack.AckedStateNum)
104+ }
105+ case ports.MsgPing:
106+ if err := token.sendActiveControl(framePong()); err != nil {
107+ token.endRoleEffect()
108+ d.detachOnSendError(token.sess, token.ac, token.transport.transport)
109+ return true
110+ }
111+ default:
112+ // Unknown/out-of-band client messages are ignored so a newer
113+ // client can add message types without breaking an older daemon.
114+ }
115+ return false
116+}
117+
118+func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
119+ ac := token.ac
120+ if token.effect == nil || token.effect.ended.Load() {
121+ return false
122+ }
123+ ac.sendMu.Lock()
124+ ac.output.ack(state)
125+ ac.sendMu.Unlock()
126+ if rc := token.sess.renderCoordinator(); rc != nil {
127+ rc.notifyAckForLease(token.lease)
128+ }
129+ return true
130+}
131+
132+// handleSnatchedClientFrame is intentionally an allow-list. Strict resume and
133+// quit actions are introduced separately; until then ordinary input is ignored.
134+func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool {
135+ if !token.current() {
136+ return false
137+ }
138+ ticket, admitted := token.ac.beginRoleEffect(token)
139+ if !admitted {
140+ return false
141+ }
142+ token.effect = ticket
143+ defer token.endRoleEffect()
144+ if d.afterRoleEffectAdmitted != nil {
145+ d.afterRoleEffectAdmitted(token)
146+ }
147+ switch f.Type {
148+ case ports.MsgInput:
149+ if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) {
150+ return true
151+ }
152+ case ports.MsgResize:
153+ if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {
154+ ac := token.ac
155+ ac.sendMu.Lock()
156+ if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
157+ ac.sendMu.Unlock()
158+ return false
159+ }
160+ ac.size = resize.Size
161+ ac.sendMu.Unlock()
162+ if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
163+ token.endRoleEffect()
164+ d.parkOrDropSnatchedAttachment(token)
165+ return true
166+ }
167+ }
168+ case ports.MsgTheme:
169+ if theme, err := ports.UnmarshalTheme(f.Payload); err == nil {
170+ ac := token.ac
171+ ac.sendMu.Lock()
172+ if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
173+ ac.sendMu.Unlock()
174+ return false
175+ }
176+ clientTheme := themeFromMessage(theme)
177+ ac.setClientTheme(clientTheme)
178+ ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
179+ ac.sendMu.Unlock()
180+ if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
181+ token.endRoleEffect()
182+ d.parkOrDropSnatchedAttachment(token)
183+ return true
184+ }
185+ }
186+ case ports.MsgAck:
187+ if ack, err := ports.UnmarshalAck(f.Payload); err == nil {
188+ token.ac.ackOutputState(ack.AckedStateNum)
189+ }
190+ case ports.MsgPing:
191+ if err := d.sendSnatchedControl(token, framePong()); err != nil {
192+ token.endRoleEffect()
193+ d.parkOrDropSnatchedAttachment(token)
194+ return true
195+ }
196+ case ports.MsgDetach:
197+ _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach))
198+ token.endRoleEffect()
199+ d.dropSnatchedAttachment(token)
200+ return true
201+ }
202+ return false
203+}
@@ -0,0 +1,203 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+// 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
7+// a transport displaced while blocked in Recv immediately adopts its restricted
8+// snatched routing rather than executing one stale active frame.
9+func (d *Daemon) runConnLoop(ac *attachedClient) {
10+ tr := ac.transport()
11+ if tr == nil {
12+ return
13+ }
14+ for {
15+ if !ac.currentTransportIs(tr) {
16+ return
17+ }
18+ sess := ac.currentSession()
19+ if sess == nil {
20+ return
21+ }
22+ f, err := tr.Recv()
23+ if err != nil {
24+ token := sess.attachmentToken(ac, tr)
25+ if token.role == attachmentSnatched {
26+ d.parkOrDropSnatchedAttachment(token)
27+ } else {
28+ d.clientGone(sess, ac, tr, false)
29+ }
30+ return
31+ }
32+ if !ac.currentTransportIs(tr) {
33+ return
34+ }
35+ sess = ac.currentSession()
36+ if sess == nil {
37+ return
38+ }
39+ token := sess.attachmentToken(ac, tr)
40+ switch token.role {
41+ case attachmentActive:
42+ if d.handleActiveClientFrame(token, f) {
43+ return
44+ }
45+ case attachmentSnatched:
46+ if d.handleSnatchedClientFrame(token, f) {
47+ return
48+ }
49+ default:
50+ return
51+ }
52+ }
53+}
54+
55+// handleActiveClientFrame owns the ordinary attached-client protocol. Keeping
56+// it separate makes the restricted snatched protocol an explicit allow-list.
57+func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {
58+ if !token.activeEffect() {
59+ return false
60+ }
61+ if d.afterActiveFrameDispatch != nil {
62+ d.afterActiveFrameDispatch(token)
63+ }
64+ ticket, admitted := token.ac.beginRoleEffect(token)
65+ if !admitted {
66+ return false
67+ }
68+ token.effect = ticket
69+ defer token.endRoleEffect()
70+ if d.afterRoleEffectAdmitted != nil {
71+ d.afterRoleEffectAdmitted(token)
72+ }
73+ switch f.Type {
74+ case ports.MsgInput:
75+ if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
76+ d.handleSequencedInputForRole(token, in.InputSeq, in.Data)
77+ }
78+ case ports.MsgResize:
79+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
80+ d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false)
81+ }
82+ case ports.MsgTheme:
83+ if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
84+ d.applyThemeForRole(token, th)
85+ }
86+ case ports.MsgImagePush:
87+ if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
88+ d.handleSequencedImagePushForRole(token, ip.InputSeq, ip)
89+ }
90+ case ports.MsgClientNotice:
91+ if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
92+ d.handleClientNoticeForRole(token, notice)
93+ } else {
94+ d.log.Warn("malformed client notice", "err", derr)
95+ }
96+ case ports.MsgDetach:
97+ if token.activeEffect() {
98+ d.clientGoneForRole(token, true)
99+ return true
100+ }
101+ case ports.MsgAck:
102+ if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
103+ d.ackActiveOutput(token, ack.AckedStateNum)
104+ }
105+ case ports.MsgPing:
106+ if err := token.sendActiveControl(framePong()); err != nil {
107+ token.endRoleEffect()
108+ d.detachOnSendError(token.sess, token.ac, token.transport.transport)
109+ return true
110+ }
111+ default:
112+ // Unknown/out-of-band client messages are ignored so a newer
113+ // client can add message types without breaking an older daemon.
114+ }
115+ return false
116+}
117+
118+func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
119+ ac := token.ac
120+ if token.effect == nil || token.effect.ended.Load() {
121+ return false
122+ }
123+ ac.sendMu.Lock()
124+ ac.output.ack(state)
125+ ac.sendMu.Unlock()
126+ if rc := token.sess.renderCoordinator(); rc != nil {
127+ rc.notifyAckForLease(token.lease)
128+ }
129+ return true
130+}
131+
132+// handleSnatchedClientFrame is intentionally an allow-list. Strict resume and
133+// quit actions are introduced separately; until then ordinary input is ignored.
134+func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool {
135+ if !token.current() {
136+ return false
137+ }
138+ ticket, admitted := token.ac.beginRoleEffect(token)
139+ if !admitted {
140+ return false
141+ }
142+ token.effect = ticket
143+ defer token.endRoleEffect()
144+ if d.afterRoleEffectAdmitted != nil {
145+ d.afterRoleEffectAdmitted(token)
146+ }
147+ switch f.Type {
148+ case ports.MsgInput:
149+ if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) {
150+ return true
151+ }
152+ case ports.MsgResize:
153+ if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {
154+ ac := token.ac
155+ ac.sendMu.Lock()
156+ if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
157+ ac.sendMu.Unlock()
158+ return false
159+ }
160+ ac.size = resize.Size
161+ ac.sendMu.Unlock()
162+ if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
163+ token.endRoleEffect()
164+ d.parkOrDropSnatchedAttachment(token)
165+ return true
166+ }
167+ }
168+ case ports.MsgTheme:
169+ if theme, err := ports.UnmarshalTheme(f.Payload); err == nil {
170+ ac := token.ac
171+ ac.sendMu.Lock()
172+ if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
173+ ac.sendMu.Unlock()
174+ return false
175+ }
176+ clientTheme := themeFromMessage(theme)
177+ ac.setClientTheme(clientTheme)
178+ ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
179+ ac.sendMu.Unlock()
180+ if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
181+ token.endRoleEffect()
182+ d.parkOrDropSnatchedAttachment(token)
183+ return true
184+ }
185+ }
186+ case ports.MsgAck:
187+ if ack, err := ports.UnmarshalAck(f.Payload); err == nil {
188+ token.ac.ackOutputState(ack.AckedStateNum)
189+ }
190+ case ports.MsgPing:
191+ if err := d.sendSnatchedControl(token, framePong()); err != nil {
192+ token.endRoleEffect()
193+ d.parkOrDropSnatchedAttachment(token)
194+ return true
195+ }
196+ case ports.MsgDetach:
197+ _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach))
198+ token.endRoleEffect()
199+ d.dropSnatchedAttachment(token)
200+ return true
201+ }
202+ return false
203+}
A internal/usecase/daemon/client_transport_errors.go +124 -0
@@ -0,0 +1,124 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+// clientGone detaches ac if it is still the session's current client. The
6+// session remains registered and headless after the client is gone.
7+func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
8+ if failed != nil && !ac.currentTransportIs(failed) {
9+ return // stale connection loop; a newer transport owns this client
10+ }
11+ if !d.detachIfCurrent(sess, ac) {
12+ return // already displaced by a newer client; nothing to do
13+ }
14+ d.finishClientGone(sess, ac, failed, explicit)
15+}
16+
17+func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
18+ if token.effect == nil {
19+ return false
20+ }
21+ token.effect.bindActionEnd(d, "detach")
22+ token.effect.End()
23+ if !d.detachIfRoleCurrent(token) {
24+ return false
25+ }
26+ d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)
27+ return true
28+}
29+
30+func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
31+ if rc := sess.renderCoordinator(); rc != nil {
32+ rc.noteDetach(ac)
33+ }
34+ d.unregisterPreview(ac)
35+ sess.mu.Lock()
36+ ephemeral := sess.ephemeral
37+ sess.mu.Unlock()
38+ if !ephemeral {
39+ d.refreshSessionCwd(sess)
40+ }
41+ d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
42+ oldTr := failed
43+ if oldTr == nil {
44+ oldTr = ac.transport()
45+ }
46+ if !explicit && d.parkAttachment(sess, ac) {
47+ _ = ac.closeCapturedTransport(oldTr)
48+ d.log.Info("client parked", "session", sess.name)
49+ return
50+ }
51+
52+ d.resetScreenDefaultColors(sess)
53+ ac.clearPreviousSession()
54+ if explicit {
55+ // Synchronous so the ack is delivered before the transport closes
56+ // (the client is actively awaiting it), but deadline-bounded so a
57+ // wedged client cannot pin this conn handler and hang Serve's
58+ // connWg.Wait.
59+ d.boundedSend(ac, frameDetached(ports.ReasonDetach))
60+ }
61+ _ = ac.closeCapturedTransport(oldTr)
62+ d.log.Info("client detached", "session", sess.name, "explicit", explicit)
63+}
64+
65+// detachOnSendError drops a client whose transport failed, leaving the session
66+// registered and headless.
67+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
68+ if failed != nil && !ac.currentTransportIs(failed) {
69+ return
70+ }
71+ if d.detachIfCurrent(sess, ac) {
72+ d.finishSendErrorDetach(sess, ac, failed)
73+ }
74+}
75+
76+func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
77+ d.detachOnRoleSendErrorUntil(token, failed, nil)
78+}
79+
80+func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
81+ if failed != token.transport.transport {
82+ return
83+ }
84+ if d.detachIfRoleCurrentUntil(token, done) {
85+ d.finishSendErrorDetach(token.sess, token.ac, failed)
86+ }
87+}
88+
89+// reserveRoleSendErrorCleanup accounts for cleanup before End releases the
90+// role gate. This closes the WaitGroup Add/Wait race with terminal teardown;
91+// the returned launch function must be invoked immediately after ticket End.
92+func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() {
93+ d.attachmentCleanupWg.Add(1)
94+ return func() {
95+ go func() {
96+ defer d.attachmentCleanupWg.Done()
97+ if d.afterRoleSendErrorCleanup != nil {
98+ defer d.afterRoleSendErrorCleanup()
99+ }
100+ if d.beforeRoleSendErrorCleanup != nil {
101+ d.beforeRoleSendErrorCleanup(token)
102+ }
103+ deadline := newRoleEffectDrainDeadline(d.clock)
104+ defer deadline.stop()
105+ d.detachOnRoleSendErrorUntil(token, failed, deadline.Done)
106+ }()
107+ }
108+}
109+
110+func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {
111+ if rc := sess.renderCoordinator(); rc != nil {
112+ rc.noteDetach(ac)
113+ }
114+ d.unregisterPreview(ac)
115+ if d.parkAttachment(sess, ac) {
116+ _ = ac.closeCapturedTransport(failed)
117+ d.log.Warn("parked client after send error", "session", sess.name)
118+ return
119+ }
120+ d.resetScreenDefaultColors(sess)
121+ ac.clearPreviousSession()
122+ _ = ac.closeCapturedTransport(failed)
123+ d.log.Warn("detached client after send error", "session", sess.name)
124+}
@@ -0,0 +1,124 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+// clientGone detaches ac if it is still the session's current client. The
6+// session remains registered and headless after the client is gone.
7+func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
8+ if failed != nil && !ac.currentTransportIs(failed) {
9+ return // stale connection loop; a newer transport owns this client
10+ }
11+ if !d.detachIfCurrent(sess, ac) {
12+ return // already displaced by a newer client; nothing to do
13+ }
14+ d.finishClientGone(sess, ac, failed, explicit)
15+}
16+
17+func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
18+ if token.effect == nil {
19+ return false
20+ }
21+ token.effect.bindActionEnd(d, "detach")
22+ token.effect.End()
23+ if !d.detachIfRoleCurrent(token) {
24+ return false
25+ }
26+ d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)
27+ return true
28+}
29+
30+func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
31+ if rc := sess.renderCoordinator(); rc != nil {
32+ rc.noteDetach(ac)
33+ }
34+ d.unregisterPreview(ac)
35+ sess.mu.Lock()
36+ ephemeral := sess.ephemeral
37+ sess.mu.Unlock()
38+ if !ephemeral {
39+ d.refreshSessionCwd(sess)
40+ }
41+ d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
42+ oldTr := failed
43+ if oldTr == nil {
44+ oldTr = ac.transport()
45+ }
46+ if !explicit && d.parkAttachment(sess, ac) {
47+ _ = ac.closeCapturedTransport(oldTr)
48+ d.log.Info("client parked", "session", sess.name)
49+ return
50+ }
51+
52+ d.resetScreenDefaultColors(sess)
53+ ac.clearPreviousSession()
54+ if explicit {
55+ // Synchronous so the ack is delivered before the transport closes
56+ // (the client is actively awaiting it), but deadline-bounded so a
57+ // wedged client cannot pin this conn handler and hang Serve's
58+ // connWg.Wait.
59+ d.boundedSend(ac, frameDetached(ports.ReasonDetach))
60+ }
61+ _ = ac.closeCapturedTransport(oldTr)
62+ d.log.Info("client detached", "session", sess.name, "explicit", explicit)
63+}
64+
65+// detachOnSendError drops a client whose transport failed, leaving the session
66+// registered and headless.
67+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
68+ if failed != nil && !ac.currentTransportIs(failed) {
69+ return
70+ }
71+ if d.detachIfCurrent(sess, ac) {
72+ d.finishSendErrorDetach(sess, ac, failed)
73+ }
74+}
75+
76+func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
77+ d.detachOnRoleSendErrorUntil(token, failed, nil)
78+}
79+
80+func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
81+ if failed != token.transport.transport {
82+ return
83+ }
84+ if d.detachIfRoleCurrentUntil(token, done) {
85+ d.finishSendErrorDetach(token.sess, token.ac, failed)
86+ }
87+}
88+
89+// reserveRoleSendErrorCleanup accounts for cleanup before End releases the
90+// role gate. This closes the WaitGroup Add/Wait race with terminal teardown;
91+// the returned launch function must be invoked immediately after ticket End.
92+func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() {
93+ d.attachmentCleanupWg.Add(1)
94+ return func() {
95+ go func() {
96+ defer d.attachmentCleanupWg.Done()
97+ if d.afterRoleSendErrorCleanup != nil {
98+ defer d.afterRoleSendErrorCleanup()
99+ }
100+ if d.beforeRoleSendErrorCleanup != nil {
101+ d.beforeRoleSendErrorCleanup(token)
102+ }
103+ deadline := newRoleEffectDrainDeadline(d.clock)
104+ defer deadline.stop()
105+ d.detachOnRoleSendErrorUntil(token, failed, deadline.Done)
106+ }()
107+ }
108+}
109+
110+func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {
111+ if rc := sess.renderCoordinator(); rc != nil {
112+ rc.noteDetach(ac)
113+ }
114+ d.unregisterPreview(ac)
115+ if d.parkAttachment(sess, ac) {
116+ _ = ac.closeCapturedTransport(failed)
117+ d.log.Warn("parked client after send error", "session", sess.name)
118+ return
119+ }
120+ d.resetScreenDefaultColors(sess)
121+ ac.clearPreviousSession()
122+ _ = ac.closeCapturedTransport(failed)
123+ d.log.Warn("detached client after send error", "session", sess.name)
124+}
A internal/usecase/daemon/move_picker.go +174 -0
@@ -0,0 +1,174 @@
1+// Package daemon holds vev's server-side session multiplexer use case.
2+package daemon
3+
4+import (
5+ "github.com/bnema/vev/internal/domain"
6+ "github.com/bnema/vev/internal/usecase/picker"
7+)
8+
9+func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
10+ if d == nil || source.Session.ID == "" || source.TabID == "" {
11+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
12+ }
13+ sess := d.sessionByID(source.Session.ID)
14+ if sess == nil || sess.incarnation != source.Session.Incarnation {
15+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
16+ }
17+ sess.mu.Lock()
18+ defer sess.mu.Unlock()
19+ if source.Client != nil && sess.client != source.Client {
20+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid)
21+ }
22+ tb := findMoveTabLocked(sess, source.TabID)
23+ if tb == nil {
24+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Tab no longer exists.", errMovePaneInvalid)
25+ }
26+ if source.PaneID == "" {
27+ return nil
28+ }
29+ tb.mu.Lock()
30+ defer tb.mu.Unlock()
31+ if paneByStableIDLocked(tb, string(source.PaneID)) == nil {
32+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Pane no longer exists.", errMovePaneInvalid)
33+ }
34+ return nil
35+}
36+
37+type pickerIntent uint8
38+
39+const (
40+ pickerNavigate pickerIntent = iota
41+ pickerMovePane
42+ pickerMoveTab
43+)
44+
45+type moveSessionLocator struct {
46+ ID domain.SessionID
47+ Incarnation domain.IncarnationID
48+ Name string
49+}
50+
51+type moveSourceLocator struct {
52+ Session moveSessionLocator
53+ TabID domain.TabStableID
54+ PaneID domain.PaneStableID
55+ Client *attachedClient
56+}
57+
58+// enterPickerForIntent returns errNoMoveDestination only for move intents.
59+func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
60+ model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})
61+ if intent != pickerNavigate {
62+ if _, ok := model.Selected(); !ok {
63+ return errNoMoveDestination
64+ }
65+ }
66+
67+ d.publishPicker(sess, ac, model, intent, source)
68+ return nil
69+}
70+
71+func pickerSelectionMode(intent pickerIntent) picker.SelectionMode {
72+ switch intent {
73+ case pickerMovePane:
74+ return picker.SelectMovePaneTab
75+ case pickerMoveTab:
76+ return picker.SelectMoveTabSession
77+ default:
78+ return picker.SelectNavigationTab
79+ }
80+}
81+
82+func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error {
83+ destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name}
84+ switch intent {
85+ case pickerMovePane:
86+ if target.TabID == "" {
87+ return errMovePaneInvalid
88+ }
89+ return d.movePane(movePaneRequest{
90+ Source: source.Session,
91+ SourceTabID: source.TabID,
92+ SourcePaneID: source.PaneID,
93+ Destination: destination,
94+ DestinationTabID: target.TabID,
95+ })
96+ case pickerMoveTab:
97+ return d.moveTab(moveTabRequest{
98+ Source: source.Session,
99+ SourceTabID: source.TabID,
100+ Destination: destination,
101+ })
102+ default:
103+ return errMovePaneInvalid
104+ }
105+}
106+
107+func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
108+ d.mu.Lock()
109+ sess := d.sessions[target.Session]
110+ if sess == nil {
111+ d.mu.Unlock()
112+ return nil, nil
113+ }
114+ sess.mu.Lock()
115+ d.mu.Unlock()
116+ defer sess.mu.Unlock()
117+ if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
118+ return nil, nil
119+ }
120+ if intent == pickerMoveTab {
121+ if sess.active < 0 || sess.active >= len(sess.tabs) {
122+ return nil, nil
123+ }
124+ return sess, sess.tabs[sess.active]
125+ }
126+ if target.TabID != "" {
127+ for _, tb := range sess.tabs {
128+ if domain.TabStableID(tb.stableID) == target.TabID {
129+ return sess, tb
130+ }
131+ }
132+ return nil, nil
133+ }
134+ if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
135+ return nil, nil
136+ }
137+ return sess, sess.tabs[target.TabIndex]
138+}
139+
140+func (d *Daemon) refreshPicker(ac *attachedClient) {
141+ sess := ac.currentSession()
142+ if sess == nil {
143+ return
144+ }
145+ rt := ac.overlays
146+ rt.pickerMu.Lock()
147+ if rt.picker == nil {
148+ rt.pickerMu.Unlock()
149+ return
150+ }
151+ intent, source := rt.pickerIntent, rt.pickerSource
152+ current := picker.SourceFilter{}
153+ if intent != pickerNavigate {
154+ selected, _ := rt.picker.Selected()
155+ current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
156+ }
157+ rt.pickerMu.Unlock()
158+ model := d.newPickerModel(sess, intent, source, current)
159+ if intent != pickerNavigate {
160+ if _, ok := model.Selected(); !ok {
161+ d.closePicker(ac)
162+ return
163+ }
164+ }
165+ rt.pickerMu.Lock()
166+ updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
167+ if updated {
168+ rt.picker = model
169+ }
170+ rt.pickerMu.Unlock()
171+ if updated {
172+ d.registerPreviewForSelection(ac)
173+ }
174+}
@@ -0,0 +1,174 @@
1+// Package daemon holds vev's server-side session multiplexer use case.
2+package daemon
3+
4+import (
5+ "github.com/bnema/vev/internal/domain"
6+ "github.com/bnema/vev/internal/usecase/picker"
7+)
8+
9+func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
10+ if d == nil || source.Session.ID == "" || source.TabID == "" {
11+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
12+ }
13+ sess := d.sessionByID(source.Session.ID)
14+ if sess == nil || sess.incarnation != source.Session.Incarnation {
15+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
16+ }
17+ sess.mu.Lock()
18+ defer sess.mu.Unlock()
19+ if source.Client != nil && sess.client != source.Client {
20+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid)
21+ }
22+ tb := findMoveTabLocked(sess, source.TabID)
23+ if tb == nil {
24+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Tab no longer exists.", errMovePaneInvalid)
25+ }
26+ if source.PaneID == "" {
27+ return nil
28+ }
29+ tb.mu.Lock()
30+ defer tb.mu.Unlock()
31+ if paneByStableIDLocked(tb, string(source.PaneID)) == nil {
32+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Pane no longer exists.", errMovePaneInvalid)
33+ }
34+ return nil
35+}
36+
37+type pickerIntent uint8
38+
39+const (
40+ pickerNavigate pickerIntent = iota
41+ pickerMovePane
42+ pickerMoveTab
43+)
44+
45+type moveSessionLocator struct {
46+ ID domain.SessionID
47+ Incarnation domain.IncarnationID
48+ Name string
49+}
50+
51+type moveSourceLocator struct {
52+ Session moveSessionLocator
53+ TabID domain.TabStableID
54+ PaneID domain.PaneStableID
55+ Client *attachedClient
56+}
57+
58+// enterPickerForIntent returns errNoMoveDestination only for move intents.
59+func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
60+ model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})
61+ if intent != pickerNavigate {
62+ if _, ok := model.Selected(); !ok {
63+ return errNoMoveDestination
64+ }
65+ }
66+
67+ d.publishPicker(sess, ac, model, intent, source)
68+ return nil
69+}
70+
71+func pickerSelectionMode(intent pickerIntent) picker.SelectionMode {
72+ switch intent {
73+ case pickerMovePane:
74+ return picker.SelectMovePaneTab
75+ case pickerMoveTab:
76+ return picker.SelectMoveTabSession
77+ default:
78+ return picker.SelectNavigationTab
79+ }
80+}
81+
82+func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error {
83+ destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name}
84+ switch intent {
85+ case pickerMovePane:
86+ if target.TabID == "" {
87+ return errMovePaneInvalid
88+ }
89+ return d.movePane(movePaneRequest{
90+ Source: source.Session,
91+ SourceTabID: source.TabID,
92+ SourcePaneID: source.PaneID,
93+ Destination: destination,
94+ DestinationTabID: target.TabID,
95+ })
96+ case pickerMoveTab:
97+ return d.moveTab(moveTabRequest{
98+ Source: source.Session,
99+ SourceTabID: source.TabID,
100+ Destination: destination,
101+ })
102+ default:
103+ return errMovePaneInvalid
104+ }
105+}
106+
107+func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
108+ d.mu.Lock()
109+ sess := d.sessions[target.Session]
110+ if sess == nil {
111+ d.mu.Unlock()
112+ return nil, nil
113+ }
114+ sess.mu.Lock()
115+ d.mu.Unlock()
116+ defer sess.mu.Unlock()
117+ if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
118+ return nil, nil
119+ }
120+ if intent == pickerMoveTab {
121+ if sess.active < 0 || sess.active >= len(sess.tabs) {
122+ return nil, nil
123+ }
124+ return sess, sess.tabs[sess.active]
125+ }
126+ if target.TabID != "" {
127+ for _, tb := range sess.tabs {
128+ if domain.TabStableID(tb.stableID) == target.TabID {
129+ return sess, tb
130+ }
131+ }
132+ return nil, nil
133+ }
134+ if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
135+ return nil, nil
136+ }
137+ return sess, sess.tabs[target.TabIndex]
138+}
139+
140+func (d *Daemon) refreshPicker(ac *attachedClient) {
141+ sess := ac.currentSession()
142+ if sess == nil {
143+ return
144+ }
145+ rt := ac.overlays
146+ rt.pickerMu.Lock()
147+ if rt.picker == nil {
148+ rt.pickerMu.Unlock()
149+ return
150+ }
151+ intent, source := rt.pickerIntent, rt.pickerSource
152+ current := picker.SourceFilter{}
153+ if intent != pickerNavigate {
154+ selected, _ := rt.picker.Selected()
155+ current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
156+ }
157+ rt.pickerMu.Unlock()
158+ model := d.newPickerModel(sess, intent, source, current)
159+ if intent != pickerNavigate {
160+ if _, ok := model.Selected(); !ok {
161+ d.closePicker(ac)
162+ return
163+ }
164+ }
165+ rt.pickerMu.Lock()
166+ updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
167+ if updated {
168+ rt.picker = model
169+ }
170+ rt.pickerMu.Unlock()
171+ if updated {
172+ d.registerPreviewForSelection(ac)
173+ }
174+}
M internal/usecase/daemon/picker.go +0 -167
@@ -14,57 +14,8 @@ import (
1414 "github.com/bnema/vev/pkg/renderer"
1515 )
1616
17-func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
18- if d == nil || source.Session.ID == "" || source.TabID == "" {
19- return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
20- }
21- sess := d.sessionByID(source.Session.ID)
22- if sess == nil || sess.incarnation != source.Session.Incarnation {
23- return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
24- }
25- sess.mu.Lock()
26- defer sess.mu.Unlock()
27- if source.Client != nil && sess.client != source.Client {
28- return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid)
29- }
30- tb := findMoveTabLocked(sess, source.TabID)
31- if tb == nil {
32- return domain.UserWarn(domain.NoticeSessionUnavailable, "Tab no longer exists.", errMovePaneInvalid)
33- }
34- if source.PaneID == "" {
35- return nil
36- }
37- tb.mu.Lock()
38- defer tb.mu.Unlock()
39- if paneByStableIDLocked(tb, string(source.PaneID)) == nil {
40- return domain.UserWarn(domain.NoticeSessionUnavailable, "Pane no longer exists.", errMovePaneInvalid)
41- }
42- return nil
43-}
44-
4517 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
4618
47-type pickerIntent uint8
48-
49-const (
50- pickerNavigate pickerIntent = iota
51- pickerMovePane
52- pickerMoveTab
53-)
54-
55-type moveSessionLocator struct {
56- ID domain.SessionID
57- Incarnation domain.IncarnationID
58- Name string
59-}
60-
61-type moveSourceLocator struct {
62- Session moveSessionLocator
63- TabID domain.TabStableID
64- PaneID domain.PaneStableID
65- Client *attachedClient
66-}
67-
6819 // enterPicker preserves the existing navigation entry point. Navigation always
6920 // publishes its model, including an empty one; only move entry can fail for a
7021 // missing destination.
@@ -73,19 +24,6 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
7324 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
7425 }
7526
76-// enterPickerForIntent returns errNoMoveDestination only for move intents.
77-func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
78- model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})
79- if intent != pickerNavigate {
80- if _, ok := model.Selected(); !ok {
81- return errNoMoveDestination
82- }
83- }
84-
85- d.publishPicker(sess, ac, model, intent, source)
86- return nil
87-}
88-
8927 func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
9028 rt := ac.overlays
9129 rt.pickerMu.Lock()
@@ -188,17 +126,6 @@ func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSo
188126 })
189127 }
190128
191-func pickerSelectionMode(intent pickerIntent) picker.SelectionMode {
192- switch intent {
193- case pickerMovePane:
194- return picker.SelectMovePaneTab
195- case pickerMoveTab:
196- return picker.SelectMoveTabSession
197- default:
198- return picker.SelectNavigationTab
199- }
200-}
201-
202129 func attentionSuffix(label string) string {
203130 return label + " " + string(ui.AttentionGlyph)
204131 }
@@ -466,31 +393,6 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
466393 }
467394 }
468395
469-func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error {
470- destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name}
471- switch intent {
472- case pickerMovePane:
473- if target.TabID == "" {
474- return errMovePaneInvalid
475- }
476- return d.movePane(movePaneRequest{
477- Source: source.Session,
478- SourceTabID: source.TabID,
479- SourcePaneID: source.PaneID,
480- Destination: destination,
481- DestinationTabID: target.TabID,
482- })
483- case pickerMoveTab:
484- return d.moveTab(moveTabRequest{
485- Source: source.Session,
486- SourceTabID: source.TabID,
487- Destination: destination,
488- })
489- default:
490- return errMovePaneInvalid
491- }
492-}
493-
494396 func (d *Daemon) closePicker(ac *attachedClient) {
495397 ac.overlays.pickerMu.Lock()
496398 ac.overlays.picker = nil
@@ -503,39 +405,6 @@ func (d *Daemon) closePicker(ac *attachedClient) {
503405 d.clearPreviewGeneration(ac, generation)
504406 }
505407
506-func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
507- d.mu.Lock()
508- sess := d.sessions[target.Session]
509- if sess == nil {
510- d.mu.Unlock()
511- return nil, nil
512- }
513- sess.mu.Lock()
514- d.mu.Unlock()
515- defer sess.mu.Unlock()
516- if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
517- return nil, nil
518- }
519- if intent == pickerMoveTab {
520- if sess.active < 0 || sess.active >= len(sess.tabs) {
521- return nil, nil
522- }
523- return sess, sess.tabs[sess.active]
524- }
525- if target.TabID != "" {
526- for _, tb := range sess.tabs {
527- if domain.TabStableID(tb.stableID) == target.TabID {
528- return sess, tb
529- }
530- }
531- return nil, nil
532- }
533- if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
534- return nil, nil
535- }
536- return sess, sess.tabs[target.TabIndex]
537-}
538-
539408 func pickerTargetsEqual(left, right picker.Target) bool {
540409 if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
541410 left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped {
@@ -951,42 +820,6 @@ func (d *Daemon) killPickerTarget(target picker.Target) error {
951820 return nil
952821 }
953822
954-func (d *Daemon) refreshPicker(ac *attachedClient) {
955- sess := ac.currentSession()
956- if sess == nil {
957- return
958- }
959- rt := ac.overlays
960- rt.pickerMu.Lock()
961- if rt.picker == nil {
962- rt.pickerMu.Unlock()
963- return
964- }
965- intent, source := rt.pickerIntent, rt.pickerSource
966- current := picker.SourceFilter{}
967- if intent != pickerNavigate {
968- selected, _ := rt.picker.Selected()
969- current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
970- }
971- rt.pickerMu.Unlock()
972- model := d.newPickerModel(sess, intent, source, current)
973- if intent != pickerNavigate {
974- if _, ok := model.Selected(); !ok {
975- d.closePicker(ac)
976- return
977- }
978- }
979- rt.pickerMu.Lock()
980- updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
981- if updated {
982- rt.picker = model
983- }
984- rt.pickerMu.Unlock()
985- if updated {
986- d.registerPreviewForSelection(ac)
987- }
988-}
989-
990823 func snapshotPickerPreview(tb *tab) picker.Preview {
991824 if tb == nil {
992825 return picker.Preview{}
@@ -14,57 +14,8 @@ import (
14 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
15 )15 )
16 16
17-func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
18- if d == nil || source.Session.ID == "" || source.TabID == "" {
19- return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
20- }
21- sess := d.sessionByID(source.Session.ID)
22- if sess == nil || sess.incarnation != source.Session.Incarnation {
23- return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
24- }
25- sess.mu.Lock()
26- defer sess.mu.Unlock()
27- if source.Client != nil && sess.client != source.Client {
28- return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid)
29- }
30- tb := findMoveTabLocked(sess, source.TabID)
31- if tb == nil {
32- return domain.UserWarn(domain.NoticeSessionUnavailable, "Tab no longer exists.", errMovePaneInvalid)
33- }
34- if source.PaneID == "" {
35- return nil
36- }
37- tb.mu.Lock()
38- defer tb.mu.Unlock()
39- if paneByStableIDLocked(tb, string(source.PaneID)) == nil {
40- return domain.UserWarn(domain.NoticeSessionUnavailable, "Pane no longer exists.", errMovePaneInvalid)
41- }
42- return nil
43-}
44-
45 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}17 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
46 18
47-type pickerIntent uint8
48-
49-const (
50- pickerNavigate pickerIntent = iota
51- pickerMovePane
52- pickerMoveTab
53-)
54-
55-type moveSessionLocator struct {
56- ID domain.SessionID
57- Incarnation domain.IncarnationID
58- Name string
59-}
60-
61-type moveSourceLocator struct {
62- Session moveSessionLocator
63- TabID domain.TabStableID
64- PaneID domain.PaneStableID
65- Client *attachedClient
66-}
67-
68 // enterPicker preserves the existing navigation entry point. Navigation always19 // enterPicker preserves the existing navigation entry point. Navigation always
69 // publishes its model, including an empty one; only move entry can fail for a20 // publishes its model, including an empty one; only move entry can fail for a
70 // missing destination.21 // missing destination.
@@ -73,19 +24,6 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
73 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})24 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
74 }25 }
75 26
76-// enterPickerForIntent returns errNoMoveDestination only for move intents.
77-func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
78- model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})
79- if intent != pickerNavigate {
80- if _, ok := model.Selected(); !ok {
81- return errNoMoveDestination
82- }
83- }
84-
85- d.publishPicker(sess, ac, model, intent, source)
86- return nil
87-}
88-
89 func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {27 func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
90 rt := ac.overlays28 rt := ac.overlays
91 rt.pickerMu.Lock()29 rt.pickerMu.Lock()
@@ -188,17 +126,6 @@ func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSo
188 })126 })
189 }127 }
190 128
191-func pickerSelectionMode(intent pickerIntent) picker.SelectionMode {
192- switch intent {
193- case pickerMovePane:
194- return picker.SelectMovePaneTab
195- case pickerMoveTab:
196- return picker.SelectMoveTabSession
197- default:
198- return picker.SelectNavigationTab
199- }
200-}
201-
202 func attentionSuffix(label string) string {129 func attentionSuffix(label string) string {
203 return label + " " + string(ui.AttentionGlyph)130 return label + " " + string(ui.AttentionGlyph)
204 }131 }
@@ -466,31 +393,6 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
466 }393 }
467 }394 }
468 395
469-func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error {
470- destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name}
471- switch intent {
472- case pickerMovePane:
473- if target.TabID == "" {
474- return errMovePaneInvalid
475- }
476- return d.movePane(movePaneRequest{
477- Source: source.Session,
478- SourceTabID: source.TabID,
479- SourcePaneID: source.PaneID,
480- Destination: destination,
481- DestinationTabID: target.TabID,
482- })
483- case pickerMoveTab:
484- return d.moveTab(moveTabRequest{
485- Source: source.Session,
486- SourceTabID: source.TabID,
487- Destination: destination,
488- })
489- default:
490- return errMovePaneInvalid
491- }
492-}
493-
494 func (d *Daemon) closePicker(ac *attachedClient) {396 func (d *Daemon) closePicker(ac *attachedClient) {
495 ac.overlays.pickerMu.Lock()397 ac.overlays.pickerMu.Lock()
496 ac.overlays.picker = nil398 ac.overlays.picker = nil
@@ -503,39 +405,6 @@ func (d *Daemon) closePicker(ac *attachedClient) {
503 d.clearPreviewGeneration(ac, generation)405 d.clearPreviewGeneration(ac, generation)
504 }406 }
505 407
506-func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
507- d.mu.Lock()
508- sess := d.sessions[target.Session]
509- if sess == nil {
510- d.mu.Unlock()
511- return nil, nil
512- }
513- sess.mu.Lock()
514- d.mu.Unlock()
515- defer sess.mu.Unlock()
516- if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
517- return nil, nil
518- }
519- if intent == pickerMoveTab {
520- if sess.active < 0 || sess.active >= len(sess.tabs) {
521- return nil, nil
522- }
523- return sess, sess.tabs[sess.active]
524- }
525- if target.TabID != "" {
526- for _, tb := range sess.tabs {
527- if domain.TabStableID(tb.stableID) == target.TabID {
528- return sess, tb
529- }
530- }
531- return nil, nil
532- }
533- if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
534- return nil, nil
535- }
536- return sess, sess.tabs[target.TabIndex]
537-}
538-
539 func pickerTargetsEqual(left, right picker.Target) bool {408 func pickerTargetsEqual(left, right picker.Target) bool {
540 if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||409 if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
541 left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped {410 left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped {
@@ -951,42 +820,6 @@ func (d *Daemon) killPickerTarget(target picker.Target) error {
951 return nil820 return nil
952 }821 }
953 822
954-func (d *Daemon) refreshPicker(ac *attachedClient) {
955- sess := ac.currentSession()
956- if sess == nil {
957- return
958- }
959- rt := ac.overlays
960- rt.pickerMu.Lock()
961- if rt.picker == nil {
962- rt.pickerMu.Unlock()
963- return
964- }
965- intent, source := rt.pickerIntent, rt.pickerSource
966- current := picker.SourceFilter{}
967- if intent != pickerNavigate {
968- selected, _ := rt.picker.Selected()
969- current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
970- }
971- rt.pickerMu.Unlock()
972- model := d.newPickerModel(sess, intent, source, current)
973- if intent != pickerNavigate {
974- if _, ok := model.Selected(); !ok {
975- d.closePicker(ac)
976- return
977- }
978- }
979- rt.pickerMu.Lock()
980- updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
981- if updated {
982- rt.picker = model
983- }
984- rt.pickerMu.Unlock()
985- if updated {
986- d.registerPreviewForSelection(ac)
987- }
988-}
989-
990 func snapshotPickerPreview(tb *tab) picker.Preview {823 func snapshotPickerPreview(tb *tab) picker.Preview {
991 if tb == nil {824 if tb == nil {
992 return picker.Preview{}825 return picker.Preview{}
A internal/usecase/daemon/resize_publication.go +301 -0
@@ -0,0 +1,301 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+type resizeCommitPublication struct {
9+ members []resizeMember
10+ session *session
11+ attachment *attachedClient
12+ lease *attachmentLease
13+ epoch uint64
14+ role attachmentRoleToken
15+ coordinator *renderCoordinator
16+ observer ports.RuntimeObserver
17+ mark ports.RuntimeMark
18+}
19+
20+func (p resizeCommitPublication) current() bool {
21+ if !resizeMembersOwnerCurrent(p.members) {
22+ return false
23+ }
24+ if p.attachment == nil {
25+ return true
26+ }
27+ if p.role.effect == nil || p.role.effect.ended.Load() || p.role.sess != p.session ||
28+ p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
29+ p.role.generation != p.attachment.roleGeneration.Load() ||
30+ !p.attachment.transportSnapshotCurrent(p.role.transport) ||
31+ p.attachment.currentSession() != p.session || p.coordinator == nil {
32+ return false
33+ }
34+ p.session.mu.Lock()
35+ active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive
36+ p.session.mu.Unlock()
37+ return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
38+}
39+
40+// emit performs observer work only after every resize-owner fence and
41+// attachment sendMu are released. The generation-bound publication is checked
42+// once more so a move that wins after geometry publication but before observer
43+// admission suppresses stale source telemetry.
44+func (p resizeCommitPublication) emit() bool {
45+ if !p.current() {
46+ return false
47+ }
48+ if p.observer != nil {
49+ p.observer.ObserveRuntime(p.mark)
50+ }
51+ return true
52+}
53+
54+// publishResizeCommit linearizes attachment geometry and an immutable telemetry
55+// reservation with pane ownership. sendMu is acquired before resize fences, so
56+// the canonical rule never acquires an attachment lock while a pane fence is
57+// held. Observer callbacks run only after both lock families are released.
58+func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
59+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
60+ if ac != nil {
61+ ac.sendMu.Lock()
62+ if d.afterResizeCommitSendLocked != nil {
63+ d.afterResizeCommitSendLocked()
64+ }
65+ }
66+ fences := acquireResizeOwnerPostEffectFences(members)
67+ if fences == nil {
68+ if ac != nil {
69+ ac.sendMu.Unlock()
70+ }
71+ return false
72+ }
73+ publication := resizeCommitPublication{
74+ members: append([]resizeMember(nil), members...),
75+ session: sess,
76+ attachment: ac,
77+ lease: lease,
78+ epoch: epoch,
79+ observer: d.runtimeObserver,
80+ mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
81+ }
82+ if ticket != nil {
83+ publication.role = ticket.roleToken()
84+ publication.coordinator = sess.renderCoordinator()
85+ }
86+ if !publication.current() {
87+ fences.Release()
88+ if ac != nil {
89+ ac.sendMu.Unlock()
90+ }
91+ return false
92+ }
93+ if ac != nil {
94+ ac.size = size
95+ }
96+ fences.Release()
97+ if ac != nil {
98+ ac.sendMu.Unlock()
99+ }
100+ return publication.emit()
101+}
102+
103+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
104+ ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
105+ if !admitted {
106+ return false
107+ }
108+ defer ticket.End()
109+ rc := sess.renderCoordinator()
110+ if rc == nil {
111+ return false
112+ }
113+ snap := rc.resizeSnapshot()
114+ current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }
115+ if !current() {
116+ return false
117+ }
118+ d.exitCopyMode(ac)
119+ result, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
120+ return rc.resizeCurrentForLease(epoch, ac, lease, true)
121+ })
122+ if !ok {
123+ return false
124+ }
125+ if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
126+ markSnapshotDirty(sess)
127+ }) {
128+ return false
129+ }
130+ if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) {
131+ return false
132+ }
133+ if len(result.failed) != 0 {
134+ var retry *resizeRetryReservation
135+ if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() {
136+ retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
137+ d.retryResizeMembers(sess, ac, lease, epoch, result.failed)
138+ })
139+ }) || retry == nil {
140+ return false
141+ }
142+ retry.finish()
143+ }
144+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
145+ if !d.publishResizeOwnerInvalidation(result.members, sess, ac, lease, epoch,
146+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
147+ return false
148+ }
149+ // The resize debounce has already elapsed. Consume this sticky reset now;
150+ // fire preserves ACK and synchronized-output gates rather than scheduling a
151+ // second urgent deadline.
152+ rc.fireCurrent(false)
153+ return true
154+}
155+
156+// retryResizeMembers retries failed committed members through a freshly
157+// prepared tab transaction. The captured members identify retry candidates
158+// only: their rectangles must never cross this delayed boundary, because any
159+// later layout mutation may have changed their geometry or removed them.
160+func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
161+ ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
162+ if !admitted {
163+ return
164+ }
165+ defer ticket.End()
166+ rc := sess.renderCoordinator()
167+ if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
168+ return
169+ }
170+
171+ // Retain tab order for deterministic external PTY ordering while collapsing
172+ // several failed members from the same tab into one canonical transaction.
173+ tabs := make([]*tab, 0, len(members))
174+ seen := make(map[*tab]struct{}, len(members))
175+ for _, member := range members {
176+ if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) {
177+ continue
178+ }
179+ if _, ok := seen[member.tab]; ok {
180+ continue
181+ }
182+ seen[member.tab] = struct{}{}
183+ tabs = append(tabs, member.tab)
184+ }
185+
186+ failed := make([]resizeMember, 0, len(members))
187+ succeeded := false
188+ for _, tb := range tabs {
189+ if !rc.retryCurrentForLease(epoch, ac, lease) {
190+ return
191+ }
192+ // A later accepted layout transaction clears resizeRetry. In that case
193+ // this delayed callback has no remaining work and, importantly, must not
194+ // replay the obsolete rectangle it captured before the mutation.
195+ retryPending := false
196+ tb.mu.Lock()
197+ for _, member := range members {
198+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
199+ continue
200+ }
201+ member.pane.mu.Lock()
202+ retryPending = retryPending || member.pane.resizeRetry
203+ member.pane.mu.Unlock()
204+ }
205+ tb.mu.Unlock()
206+ if !retryPending {
207+ continue
208+ }
209+
210+ // applyTabLayoutTransaction captures the current generation, pane
211+ // pointers, and solved rectangles, and validates them again before any
212+ // VT/rectangle publication. It also preserves the resize gate and
213+ // degradation notice behavior for another failed external attempt.
214+ result, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
215+ return rc.retryCurrentForLease(epoch, ac, lease)
216+ })
217+ if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
218+ return
219+ }
220+ failed = append(failed, result.failed...)
221+ // A retry success is a formerly failed, still-current target whose
222+ // canonical apply cleared its retry bit. A collapsed or removed target
223+ // is neither a retry completion nor a reason to publish a reset.
224+ tb.mu.Lock()
225+ for _, member := range members {
226+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
227+ continue
228+ }
229+ member.pane.mu.Lock()
230+ succeeded = succeeded || !member.pane.resizeRetry
231+ member.pane.mu.Unlock()
232+ }
233+ tb.mu.Unlock()
234+ }
235+ for _, member := range members {
236+ if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrentLocked(&member) {
237+ continue
238+ }
239+ if !rc.retryCurrentForLease(epoch, ac, lease) {
240+ return
241+ }
242+ // Floating panes are outside tb.panes. Validate the exact accepted slot
243+ // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
244+ // the same pointer/generation check around external PTY.Resize.
245+ member.tab.mu.Lock()
246+ currentSlot := member.tab.floating.state == floatingVisible &&
247+ member.tab.floating.generation == member.floatingGeneration &&
248+ member.tab.floating.pane == member.pane &&
249+ resizeMemberOwnerCurrentLocked(&member)
250+ retryPending := false
251+ if currentSlot {
252+ member.pane.mu.Lock()
253+ retryPending = member.pane.resizeRetry
254+ member.pane.mu.Unlock()
255+ }
256+ member.tab.mu.Unlock()
257+ if !currentSlot || !retryPending {
258+ continue
259+ }
260+ result, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
261+ return rc.retryCurrentForLease(epoch, ac, lease)
262+ }, &member)
263+ if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
264+ return
265+ }
266+ failed = append(failed, result.failed...)
267+ member.tab.mu.Lock()
268+ stillCurrent := member.tab.floating.state == floatingVisible &&
269+ member.tab.floating.generation == member.floatingGeneration &&
270+ member.tab.floating.pane == member.pane &&
271+ resizeMemberOwnerCurrentLocked(&member)
272+ member.tab.mu.Unlock()
273+ if stillCurrent && len(result.failed) == 0 {
274+ member.pane.mu.Lock()
275+ succeeded = succeeded || !member.pane.resizeRetry
276+ member.pane.mu.Unlock()
277+ }
278+ }
279+ if len(failed) != 0 {
280+ var retry *resizeRetryReservation
281+ if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() {
282+ retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
283+ d.retryResizeMembers(sess, ac, lease, epoch, failed)
284+ })
285+ }) || retry == nil {
286+ return
287+ }
288+ retry.finish()
289+ }
290+ if succeeded {
291+ // Retry completion changes VT state after the original layout commit.
292+ // Keep a named session's eventual snapshot generation aligned with it.
293+ if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() {
294+ markSnapshotDirty(sess)
295+ }) {
296+ return
297+ }
298+ d.publishResizeOwnerInvalidation(members, sess, ac, lease, 0,
299+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
300+ }
301+}
@@ -0,0 +1,301 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+type resizeCommitPublication struct {
9+ members []resizeMember
10+ session *session
11+ attachment *attachedClient
12+ lease *attachmentLease
13+ epoch uint64
14+ role attachmentRoleToken
15+ coordinator *renderCoordinator
16+ observer ports.RuntimeObserver
17+ mark ports.RuntimeMark
18+}
19+
20+func (p resizeCommitPublication) current() bool {
21+ if !resizeMembersOwnerCurrent(p.members) {
22+ return false
23+ }
24+ if p.attachment == nil {
25+ return true
26+ }
27+ if p.role.effect == nil || p.role.effect.ended.Load() || p.role.sess != p.session ||
28+ p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
29+ p.role.generation != p.attachment.roleGeneration.Load() ||
30+ !p.attachment.transportSnapshotCurrent(p.role.transport) ||
31+ p.attachment.currentSession() != p.session || p.coordinator == nil {
32+ return false
33+ }
34+ p.session.mu.Lock()
35+ active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive
36+ p.session.mu.Unlock()
37+ return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
38+}
39+
40+// emit performs observer work only after every resize-owner fence and
41+// attachment sendMu are released. The generation-bound publication is checked
42+// once more so a move that wins after geometry publication but before observer
43+// admission suppresses stale source telemetry.
44+func (p resizeCommitPublication) emit() bool {
45+ if !p.current() {
46+ return false
47+ }
48+ if p.observer != nil {
49+ p.observer.ObserveRuntime(p.mark)
50+ }
51+ return true
52+}
53+
54+// publishResizeCommit linearizes attachment geometry and an immutable telemetry
55+// reservation with pane ownership. sendMu is acquired before resize fences, so
56+// the canonical rule never acquires an attachment lock while a pane fence is
57+// held. Observer callbacks run only after both lock families are released.
58+func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
59+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
60+ if ac != nil {
61+ ac.sendMu.Lock()
62+ if d.afterResizeCommitSendLocked != nil {
63+ d.afterResizeCommitSendLocked()
64+ }
65+ }
66+ fences := acquireResizeOwnerPostEffectFences(members)
67+ if fences == nil {
68+ if ac != nil {
69+ ac.sendMu.Unlock()
70+ }
71+ return false
72+ }
73+ publication := resizeCommitPublication{
74+ members: append([]resizeMember(nil), members...),
75+ session: sess,
76+ attachment: ac,
77+ lease: lease,
78+ epoch: epoch,
79+ observer: d.runtimeObserver,
80+ mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
81+ }
82+ if ticket != nil {
83+ publication.role = ticket.roleToken()
84+ publication.coordinator = sess.renderCoordinator()
85+ }
86+ if !publication.current() {
87+ fences.Release()
88+ if ac != nil {
89+ ac.sendMu.Unlock()
90+ }
91+ return false
92+ }
93+ if ac != nil {
94+ ac.size = size
95+ }
96+ fences.Release()
97+ if ac != nil {
98+ ac.sendMu.Unlock()
99+ }
100+ return publication.emit()
101+}
102+
103+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
104+ ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
105+ if !admitted {
106+ return false
107+ }
108+ defer ticket.End()
109+ rc := sess.renderCoordinator()
110+ if rc == nil {
111+ return false
112+ }
113+ snap := rc.resizeSnapshot()
114+ current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }
115+ if !current() {
116+ return false
117+ }
118+ d.exitCopyMode(ac)
119+ result, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
120+ return rc.resizeCurrentForLease(epoch, ac, lease, true)
121+ })
122+ if !ok {
123+ return false
124+ }
125+ if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
126+ markSnapshotDirty(sess)
127+ }) {
128+ return false
129+ }
130+ if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) {
131+ return false
132+ }
133+ if len(result.failed) != 0 {
134+ var retry *resizeRetryReservation
135+ if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() {
136+ retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
137+ d.retryResizeMembers(sess, ac, lease, epoch, result.failed)
138+ })
139+ }) || retry == nil {
140+ return false
141+ }
142+ retry.finish()
143+ }
144+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
145+ if !d.publishResizeOwnerInvalidation(result.members, sess, ac, lease, epoch,
146+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
147+ return false
148+ }
149+ // The resize debounce has already elapsed. Consume this sticky reset now;
150+ // fire preserves ACK and synchronized-output gates rather than scheduling a
151+ // second urgent deadline.
152+ rc.fireCurrent(false)
153+ return true
154+}
155+
156+// retryResizeMembers retries failed committed members through a freshly
157+// prepared tab transaction. The captured members identify retry candidates
158+// only: their rectangles must never cross this delayed boundary, because any
159+// later layout mutation may have changed their geometry or removed them.
160+func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
161+ ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
162+ if !admitted {
163+ return
164+ }
165+ defer ticket.End()
166+ rc := sess.renderCoordinator()
167+ if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
168+ return
169+ }
170+
171+ // Retain tab order for deterministic external PTY ordering while collapsing
172+ // several failed members from the same tab into one canonical transaction.
173+ tabs := make([]*tab, 0, len(members))
174+ seen := make(map[*tab]struct{}, len(members))
175+ for _, member := range members {
176+ if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) {
177+ continue
178+ }
179+ if _, ok := seen[member.tab]; ok {
180+ continue
181+ }
182+ seen[member.tab] = struct{}{}
183+ tabs = append(tabs, member.tab)
184+ }
185+
186+ failed := make([]resizeMember, 0, len(members))
187+ succeeded := false
188+ for _, tb := range tabs {
189+ if !rc.retryCurrentForLease(epoch, ac, lease) {
190+ return
191+ }
192+ // A later accepted layout transaction clears resizeRetry. In that case
193+ // this delayed callback has no remaining work and, importantly, must not
194+ // replay the obsolete rectangle it captured before the mutation.
195+ retryPending := false
196+ tb.mu.Lock()
197+ for _, member := range members {
198+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
199+ continue
200+ }
201+ member.pane.mu.Lock()
202+ retryPending = retryPending || member.pane.resizeRetry
203+ member.pane.mu.Unlock()
204+ }
205+ tb.mu.Unlock()
206+ if !retryPending {
207+ continue
208+ }
209+
210+ // applyTabLayoutTransaction captures the current generation, pane
211+ // pointers, and solved rectangles, and validates them again before any
212+ // VT/rectangle publication. It also preserves the resize gate and
213+ // degradation notice behavior for another failed external attempt.
214+ result, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
215+ return rc.retryCurrentForLease(epoch, ac, lease)
216+ })
217+ if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
218+ return
219+ }
220+ failed = append(failed, result.failed...)
221+ // A retry success is a formerly failed, still-current target whose
222+ // canonical apply cleared its retry bit. A collapsed or removed target
223+ // is neither a retry completion nor a reason to publish a reset.
224+ tb.mu.Lock()
225+ for _, member := range members {
226+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
227+ continue
228+ }
229+ member.pane.mu.Lock()
230+ succeeded = succeeded || !member.pane.resizeRetry
231+ member.pane.mu.Unlock()
232+ }
233+ tb.mu.Unlock()
234+ }
235+ for _, member := range members {
236+ if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrentLocked(&member) {
237+ continue
238+ }
239+ if !rc.retryCurrentForLease(epoch, ac, lease) {
240+ return
241+ }
242+ // Floating panes are outside tb.panes. Validate the exact accepted slot
243+ // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
244+ // the same pointer/generation check around external PTY.Resize.
245+ member.tab.mu.Lock()
246+ currentSlot := member.tab.floating.state == floatingVisible &&
247+ member.tab.floating.generation == member.floatingGeneration &&
248+ member.tab.floating.pane == member.pane &&
249+ resizeMemberOwnerCurrentLocked(&member)
250+ retryPending := false
251+ if currentSlot {
252+ member.pane.mu.Lock()
253+ retryPending = member.pane.resizeRetry
254+ member.pane.mu.Unlock()
255+ }
256+ member.tab.mu.Unlock()
257+ if !currentSlot || !retryPending {
258+ continue
259+ }
260+ result, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
261+ return rc.retryCurrentForLease(epoch, ac, lease)
262+ }, &member)
263+ if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
264+ return
265+ }
266+ failed = append(failed, result.failed...)
267+ member.tab.mu.Lock()
268+ stillCurrent := member.tab.floating.state == floatingVisible &&
269+ member.tab.floating.generation == member.floatingGeneration &&
270+ member.tab.floating.pane == member.pane &&
271+ resizeMemberOwnerCurrentLocked(&member)
272+ member.tab.mu.Unlock()
273+ if stillCurrent && len(result.failed) == 0 {
274+ member.pane.mu.Lock()
275+ succeeded = succeeded || !member.pane.resizeRetry
276+ member.pane.mu.Unlock()
277+ }
278+ }
279+ if len(failed) != 0 {
280+ var retry *resizeRetryReservation
281+ if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() {
282+ retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
283+ d.retryResizeMembers(sess, ac, lease, epoch, failed)
284+ })
285+ }) || retry == nil {
286+ return
287+ }
288+ retry.finish()
289+ }
290+ if succeeded {
291+ // Retry completion changes VT state after the original layout commit.
292+ // Keep a named session's eventual snapshot generation aligned with it.
293+ if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() {
294+ markSnapshotDirty(sess)
295+ }) {
296+ return
297+ }
298+ d.publishResizeOwnerInvalidation(members, sess, ac, lease, 0,
299+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
300+ }
301+}
A internal/usecase/daemon/resize_retry.go +136 -0
@@ -0,0 +1,136 @@
1+package daemon
2+
3+import "context"
4+
5+const maxAcceptedTabLayoutRetries = 3
6+
7+// acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane
8+// owner generations which accepted the failure. A moved pane cannot lend its
9+// old source worker authority over either the PTY or source publications.
10+type acceptedTabLayoutRetryToken struct {
11+ session *session
12+ tab *tab
13+ members []resizeMember
14+}
15+
16+func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken {
17+ return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)}
18+}
19+
20+func (t acceptedTabLayoutRetryToken) current() bool {
21+ if t.session == nil || t.tab == nil || len(t.members) == 0 {
22+ return false
23+ }
24+ for i := range t.members {
25+ member := &t.members[i]
26+ if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrentLocked(member) {
27+ return false
28+ }
29+ }
30+ return true
31+}
32+
33+// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
34+// worker is deduplicated, derives cancellation from the tab lifecycle, and
35+// suppresses repeat degradation notices after the accepted initial failure.
36+func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab, failed ...[]resizeMember) bool {
37+ if d.clock == nil || tb == nil || tb.ctx == nil {
38+ return false
39+ }
40+ var members []resizeMember
41+ if len(failed) != 0 {
42+ members = failed[0]
43+ } else {
44+ tb.mu.Lock()
45+ plan := prepareTabLayoutLocked(sess, tb)
46+ tb.mu.Unlock()
47+ for i := range plan.members {
48+ member := plan.members[i]
49+ member.pane.mu.Lock()
50+ pending := member.pane.resizeRetry
51+ member.pane.mu.Unlock()
52+ if pending {
53+ members = append(members, member)
54+ }
55+ }
56+ }
57+ token := newAcceptedTabLayoutRetryToken(sess, tb, members)
58+ var ctx context.Context
59+ var cancel context.CancelFunc
60+ start := false
61+ if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRetrySchedule, func() {
62+ tb.layoutRetryMu.Lock()
63+ defer tb.layoutRetryMu.Unlock()
64+ if tb.layoutRetryRunning {
65+ return
66+ }
67+ ctx, cancel = context.WithCancel(tb.ctx)
68+ tb.layoutRetryRunning = true
69+ tb.layoutRetryCancel = cancel
70+ start = true
71+ }) {
72+ return false
73+ }
74+ if !start {
75+ return true
76+ }
77+
78+ go func() {
79+ defer func() {
80+ cancel()
81+ tb.layoutRetryMu.Lock()
82+ tb.layoutRetryRunning = false
83+ tb.layoutRetryCancel = nil
84+ tb.layoutRetryMu.Unlock()
85+ }()
86+ for range maxAcceptedTabLayoutRetries {
87+ if !token.current() {
88+ return
89+ }
90+ timer := d.clock.NewTimer(minOutputRenderDeadline)
91+ if timer == nil {
92+ return
93+ }
94+ timerC := timer.C()
95+ if timerC == nil {
96+ timer.Stop()
97+ return
98+ }
99+ select {
100+ case <-ctx.Done():
101+ timer.Stop()
102+ return
103+ case <-timerC:
104+ }
105+ if ctx.Err() != nil || !token.current() {
106+ return
107+ }
108+ result, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool {
109+ return ctx.Err() == nil && token.current()
110+ })
111+ if !ok || !token.current() {
112+ return
113+ }
114+ if len(result.failed) == 0 {
115+ return
116+ }
117+ token = newAcceptedTabLayoutRetryToken(sess, tb, result.failed)
118+ if !token.current() {
119+ return
120+ }
121+ if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
122+ markSnapshotDirty(sess)
123+ }) {
124+ return
125+ }
126+ sess.mu.Lock()
127+ ac := sess.client
128+ sess.mu.Unlock()
129+ if ac != nil && !d.publishResizeOwnerInvalidation(result.members, sess, ac, nil, 0,
130+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
131+ return
132+ }
133+ }
134+ }()
135+ return true
136+}
@@ -0,0 +1,136 @@
1+package daemon
2+
3+import "context"
4+
5+const maxAcceptedTabLayoutRetries = 3
6+
7+// acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane
8+// owner generations which accepted the failure. A moved pane cannot lend its
9+// old source worker authority over either the PTY or source publications.
10+type acceptedTabLayoutRetryToken struct {
11+ session *session
12+ tab *tab
13+ members []resizeMember
14+}
15+
16+func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken {
17+ return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)}
18+}
19+
20+func (t acceptedTabLayoutRetryToken) current() bool {
21+ if t.session == nil || t.tab == nil || len(t.members) == 0 {
22+ return false
23+ }
24+ for i := range t.members {
25+ member := &t.members[i]
26+ if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrentLocked(member) {
27+ return false
28+ }
29+ }
30+ return true
31+}
32+
33+// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
34+// worker is deduplicated, derives cancellation from the tab lifecycle, and
35+// suppresses repeat degradation notices after the accepted initial failure.
36+func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab, failed ...[]resizeMember) bool {
37+ if d.clock == nil || tb == nil || tb.ctx == nil {
38+ return false
39+ }
40+ var members []resizeMember
41+ if len(failed) != 0 {
42+ members = failed[0]
43+ } else {
44+ tb.mu.Lock()
45+ plan := prepareTabLayoutLocked(sess, tb)
46+ tb.mu.Unlock()
47+ for i := range plan.members {
48+ member := plan.members[i]
49+ member.pane.mu.Lock()
50+ pending := member.pane.resizeRetry
51+ member.pane.mu.Unlock()
52+ if pending {
53+ members = append(members, member)
54+ }
55+ }
56+ }
57+ token := newAcceptedTabLayoutRetryToken(sess, tb, members)
58+ var ctx context.Context
59+ var cancel context.CancelFunc
60+ start := false
61+ if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRetrySchedule, func() {
62+ tb.layoutRetryMu.Lock()
63+ defer tb.layoutRetryMu.Unlock()
64+ if tb.layoutRetryRunning {
65+ return
66+ }
67+ ctx, cancel = context.WithCancel(tb.ctx)
68+ tb.layoutRetryRunning = true
69+ tb.layoutRetryCancel = cancel
70+ start = true
71+ }) {
72+ return false
73+ }
74+ if !start {
75+ return true
76+ }
77+
78+ go func() {
79+ defer func() {
80+ cancel()
81+ tb.layoutRetryMu.Lock()
82+ tb.layoutRetryRunning = false
83+ tb.layoutRetryCancel = nil
84+ tb.layoutRetryMu.Unlock()
85+ }()
86+ for range maxAcceptedTabLayoutRetries {
87+ if !token.current() {
88+ return
89+ }
90+ timer := d.clock.NewTimer(minOutputRenderDeadline)
91+ if timer == nil {
92+ return
93+ }
94+ timerC := timer.C()
95+ if timerC == nil {
96+ timer.Stop()
97+ return
98+ }
99+ select {
100+ case <-ctx.Done():
101+ timer.Stop()
102+ return
103+ case <-timerC:
104+ }
105+ if ctx.Err() != nil || !token.current() {
106+ return
107+ }
108+ result, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool {
109+ return ctx.Err() == nil && token.current()
110+ })
111+ if !ok || !token.current() {
112+ return
113+ }
114+ if len(result.failed) == 0 {
115+ return
116+ }
117+ token = newAcceptedTabLayoutRetryToken(sess, tb, result.failed)
118+ if !token.current() {
119+ return
120+ }
121+ if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
122+ markSnapshotDirty(sess)
123+ }) {
124+ return
125+ }
126+ sess.mu.Lock()
127+ ac := sess.client
128+ sess.mu.Unlock()
129+ if ac != nil && !d.publishResizeOwnerInvalidation(result.members, sess, ac, nil, 0,
130+ renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
131+ return
132+ }
133+ }
134+ }()
135+ return true
136+}
M internal/usecase/daemon/transactional_resize.go +0 -430
@@ -1,8 +1,6 @@
11 package daemon
22
33 import (
4- "context"
5-
64 "github.com/bnema/vev/internal/domain"
75 "github.com/bnema/vev/internal/ports"
86 "github.com/bnema/vev/internal/usecase/layout"
@@ -349,139 +347,6 @@ func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
349347 return true
350348 }
351349
352-const maxAcceptedTabLayoutRetries = 3
353-
354-// acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane
355-// owner generations which accepted the failure. A moved pane cannot lend its
356-// old source worker authority over either the PTY or source publications.
357-type acceptedTabLayoutRetryToken struct {
358- session *session
359- tab *tab
360- members []resizeMember
361-}
362-
363-func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken {
364- return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)}
365-}
366-
367-func (t acceptedTabLayoutRetryToken) current() bool {
368- if t.session == nil || t.tab == nil || len(t.members) == 0 {
369- return false
370- }
371- for i := range t.members {
372- member := &t.members[i]
373- if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrentLocked(member) {
374- return false
375- }
376- }
377- return true
378-}
379-
380-// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
381-// worker is deduplicated, derives cancellation from the tab lifecycle, and
382-// suppresses repeat degradation notices after the accepted initial failure.
383-func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab, failed ...[]resizeMember) bool {
384- if d.clock == nil || tb == nil || tb.ctx == nil {
385- return false
386- }
387- var members []resizeMember
388- if len(failed) != 0 {
389- members = failed[0]
390- } else {
391- tb.mu.Lock()
392- plan := prepareTabLayoutLocked(sess, tb)
393- tb.mu.Unlock()
394- for i := range plan.members {
395- member := plan.members[i]
396- member.pane.mu.Lock()
397- pending := member.pane.resizeRetry
398- member.pane.mu.Unlock()
399- if pending {
400- members = append(members, member)
401- }
402- }
403- }
404- token := newAcceptedTabLayoutRetryToken(sess, tb, members)
405- var ctx context.Context
406- var cancel context.CancelFunc
407- start := false
408- if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRetrySchedule, func() {
409- tb.layoutRetryMu.Lock()
410- defer tb.layoutRetryMu.Unlock()
411- if tb.layoutRetryRunning {
412- return
413- }
414- ctx, cancel = context.WithCancel(tb.ctx)
415- tb.layoutRetryRunning = true
416- tb.layoutRetryCancel = cancel
417- start = true
418- }) {
419- return false
420- }
421- if !start {
422- return true
423- }
424-
425- go func() {
426- defer func() {
427- cancel()
428- tb.layoutRetryMu.Lock()
429- tb.layoutRetryRunning = false
430- tb.layoutRetryCancel = nil
431- tb.layoutRetryMu.Unlock()
432- }()
433- for range maxAcceptedTabLayoutRetries {
434- if !token.current() {
435- return
436- }
437- timer := d.clock.NewTimer(minOutputRenderDeadline)
438- if timer == nil {
439- return
440- }
441- timerC := timer.C()
442- if timerC == nil {
443- timer.Stop()
444- return
445- }
446- select {
447- case <-ctx.Done():
448- timer.Stop()
449- return
450- case <-timerC:
451- }
452- if ctx.Err() != nil || !token.current() {
453- return
454- }
455- result, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool {
456- return ctx.Err() == nil && token.current()
457- })
458- if !ok || !token.current() {
459- return
460- }
461- if len(result.failed) == 0 {
462- return
463- }
464- token = newAcceptedTabLayoutRetryToken(sess, tb, result.failed)
465- if !token.current() {
466- return
467- }
468- if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
469- markSnapshotDirty(sess)
470- }) {
471- return
472- }
473- sess.mu.Lock()
474- ac := sess.client
475- sess.mu.Unlock()
476- if ac != nil && !d.publishResizeOwnerInvalidation(result.members, sess, ac, nil, 0,
477- renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
478- return
479- }
480- }
481- }()
482- return true
483-}
484-
485350 // replayResizePending completes a pane's apply epoch. It keeps the gate set
486351 // while each buffered batch goes through the normal VT path, so reads arriving
487352 // during replay join the same ordered stream. Failure deliberately omits the
@@ -751,301 +616,6 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
751616 }
752617 }
753618
754-type resizeCommitPublication struct {
755- members []resizeMember
756- session *session
757- attachment *attachedClient
758- lease *attachmentLease
759- epoch uint64
760- role attachmentRoleToken
761- coordinator *renderCoordinator
762- observer ports.RuntimeObserver
763- mark ports.RuntimeMark
764-}
765-
766-func (p resizeCommitPublication) current() bool {
767- if !resizeMembersOwnerCurrent(p.members) {
768- return false
769- }
770- if p.attachment == nil {
771- return true
772- }
773- if p.role.effect == nil || p.role.effect.ended.Load() || p.role.sess != p.session ||
774- p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
775- p.role.generation != p.attachment.roleGeneration.Load() ||
776- !p.attachment.transportSnapshotCurrent(p.role.transport) ||
777- p.attachment.currentSession() != p.session || p.coordinator == nil {
778- return false
779- }
780- p.session.mu.Lock()
781- active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive
782- p.session.mu.Unlock()
783- return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
784-}
785-
786-// emit performs observer work only after every resize-owner fence and
787-// attachment sendMu are released. The generation-bound publication is checked
788-// once more so a move that wins after geometry publication but before observer
789-// admission suppresses stale source telemetry.
790-func (p resizeCommitPublication) emit() bool {
791- if !p.current() {
792- return false
793- }
794- if p.observer != nil {
795- p.observer.ObserveRuntime(p.mark)
796- }
797- return true
798-}
799-
800-// publishResizeCommit linearizes attachment geometry and an immutable telemetry
801-// reservation with pane ownership. sendMu is acquired before resize fences, so
802-// the canonical rule never acquires an attachment lock while a pane fence is
803-// held. Observer callbacks run only after both lock families are released.
804-func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
805- d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
806- if ac != nil {
807- ac.sendMu.Lock()
808- if d.afterResizeCommitSendLocked != nil {
809- d.afterResizeCommitSendLocked()
810- }
811- }
812- fences := acquireResizeOwnerPostEffectFences(members)
813- if fences == nil {
814- if ac != nil {
815- ac.sendMu.Unlock()
816- }
817- return false
818- }
819- publication := resizeCommitPublication{
820- members: append([]resizeMember(nil), members...),
821- session: sess,
822- attachment: ac,
823- lease: lease,
824- epoch: epoch,
825- observer: d.runtimeObserver,
826- mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
827- }
828- if ticket != nil {
829- publication.role = ticket.roleToken()
830- publication.coordinator = sess.renderCoordinator()
831- }
832- if !publication.current() {
833- fences.Release()
834- if ac != nil {
835- ac.sendMu.Unlock()
836- }
837- return false
838- }
839- if ac != nil {
840- ac.size = size
841- }
842- fences.Release()
843- if ac != nil {
844- ac.sendMu.Unlock()
845- }
846- return publication.emit()
847-}
848-
849-func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
850- ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
851- if !admitted {
852- return false
853- }
854- defer ticket.End()
855- rc := sess.renderCoordinator()
856- if rc == nil {
857- return false
858- }
859- snap := rc.resizeSnapshot()
860- current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }
861- if !current() {
862- return false
863- }
864- d.exitCopyMode(ac)
865- result, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
866- return rc.resizeCurrentForLease(epoch, ac, lease, true)
867- })
868- if !ok {
869- return false
870- }
871- if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
872- markSnapshotDirty(sess)
873- }) {
874- return false
875- }
876- if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) {
877- return false
878- }
879- if len(result.failed) != 0 {
880- var retry *resizeRetryReservation
881- if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() {
882- retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
883- d.retryResizeMembers(sess, ac, lease, epoch, result.failed)
884- })
885- }) || retry == nil {
886- return false
887- }
888- retry.finish()
889- }
890- d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
891- if !d.publishResizeOwnerInvalidation(result.members, sess, ac, lease, epoch,
892- renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
893- return false
894- }
895- // The resize debounce has already elapsed. Consume this sticky reset now;
896- // fire preserves ACK and synchronized-output gates rather than scheduling a
897- // second urgent deadline.
898- rc.fireCurrent(false)
899- return true
900-}
901-
902-// retryResizeMembers retries failed committed members through a freshly
903-// prepared tab transaction. The captured members identify retry candidates
904-// only: their rectangles must never cross this delayed boundary, because any
905-// later layout mutation may have changed their geometry or removed them.
906-func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
907- ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
908- if !admitted {
909- return
910- }
911- defer ticket.End()
912- rc := sess.renderCoordinator()
913- if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
914- return
915- }
916-
917- // Retain tab order for deterministic external PTY ordering while collapsing
918- // several failed members from the same tab into one canonical transaction.
919- tabs := make([]*tab, 0, len(members))
920- seen := make(map[*tab]struct{}, len(members))
921- for _, member := range members {
922- if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) {
923- continue
924- }
925- if _, ok := seen[member.tab]; ok {
926- continue
927- }
928- seen[member.tab] = struct{}{}
929- tabs = append(tabs, member.tab)
930- }
931-
932- failed := make([]resizeMember, 0, len(members))
933- succeeded := false
934- for _, tb := range tabs {
935- if !rc.retryCurrentForLease(epoch, ac, lease) {
936- return
937- }
938- // A later accepted layout transaction clears resizeRetry. In that case
939- // this delayed callback has no remaining work and, importantly, must not
940- // replay the obsolete rectangle it captured before the mutation.
941- retryPending := false
942- tb.mu.Lock()
943- for _, member := range members {
944- if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
945- continue
946- }
947- member.pane.mu.Lock()
948- retryPending = retryPending || member.pane.resizeRetry
949- member.pane.mu.Unlock()
950- }
951- tb.mu.Unlock()
952- if !retryPending {
953- continue
954- }
955-
956- // applyTabLayoutTransaction captures the current generation, pane
957- // pointers, and solved rectangles, and validates them again before any
958- // VT/rectangle publication. It also preserves the resize gate and
959- // degradation notice behavior for another failed external attempt.
960- result, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
961- return rc.retryCurrentForLease(epoch, ac, lease)
962- })
963- if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
964- return
965- }
966- failed = append(failed, result.failed...)
967- // A retry success is a formerly failed, still-current target whose
968- // canonical apply cleared its retry bit. A collapsed or removed target
969- // is neither a retry completion nor a reason to publish a reset.
970- tb.mu.Lock()
971- for _, member := range members {
972- if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
973- continue
974- }
975- member.pane.mu.Lock()
976- succeeded = succeeded || !member.pane.resizeRetry
977- member.pane.mu.Unlock()
978- }
979- tb.mu.Unlock()
980- }
981- for _, member := range members {
982- if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrentLocked(&member) {
983- continue
984- }
985- if !rc.retryCurrentForLease(epoch, ac, lease) {
986- return
987- }
988- // Floating panes are outside tb.panes. Validate the exact accepted slot
989- // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
990- // the same pointer/generation check around external PTY.Resize.
991- member.tab.mu.Lock()
992- currentSlot := member.tab.floating.state == floatingVisible &&
993- member.tab.floating.generation == member.floatingGeneration &&
994- member.tab.floating.pane == member.pane &&
995- resizeMemberOwnerCurrentLocked(&member)
996- retryPending := false
997- if currentSlot {
998- member.pane.mu.Lock()
999- retryPending = member.pane.resizeRetry
1000- member.pane.mu.Unlock()
1001- }
1002- member.tab.mu.Unlock()
1003- if !currentSlot || !retryPending {
1004- continue
1005- }
1006- result, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
1007- return rc.retryCurrentForLease(epoch, ac, lease)
1008- }, &member)
1009- if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
1010- return
1011- }
1012- failed = append(failed, result.failed...)
1013- member.tab.mu.Lock()
1014- stillCurrent := member.tab.floating.state == floatingVisible &&
1015- member.tab.floating.generation == member.floatingGeneration &&
1016- member.tab.floating.pane == member.pane &&
1017- resizeMemberOwnerCurrentLocked(&member)
1018- member.tab.mu.Unlock()
1019- if stillCurrent && len(result.failed) == 0 {
1020- member.pane.mu.Lock()
1021- succeeded = succeeded || !member.pane.resizeRetry
1022- member.pane.mu.Unlock()
1023- }
1024- }
1025- if len(failed) != 0 {
1026- var retry *resizeRetryReservation
1027- if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() {
1028- retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
1029- d.retryResizeMembers(sess, ac, lease, epoch, failed)
1030- })
1031- }) || retry == nil {
1032- return
1033- }
1034- retry.finish()
1035- }
1036- if succeeded {
1037- // Retry completion changes VT state after the original layout commit.
1038- // Keep a named session's eventual snapshot generation aligned with it.
1039- if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() {
1040- markSnapshotDirty(sess)
1041- }) {
1042- return
1043- }
1044- d.publishResizeOwnerInvalidation(members, sess, ac, lease, 0,
1045- renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
1046- }
1047-}
1048-
1049619 // requestTransactionalResize reports reset invalidation completion for immediate
1050620 // attached requests, and coordinator schedule acceptance for async requests.
1051621 // Headless requests have no reset invalidation and report geometry completion.
@@ -1,8 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "context"
5-
6 "github.com/bnema/vev/internal/domain"4 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"5 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/layout"6 "github.com/bnema/vev/internal/usecase/layout"
@@ -349,139 +347,6 @@ func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
349 return true347 return true
350 }348 }
351 349
352-const maxAcceptedTabLayoutRetries = 3
353-
354-// acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane
355-// owner generations which accepted the failure. A moved pane cannot lend its
356-// old source worker authority over either the PTY or source publications.
357-type acceptedTabLayoutRetryToken struct {
358- session *session
359- tab *tab
360- members []resizeMember
361-}
362-
363-func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken {
364- return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)}
365-}
366-
367-func (t acceptedTabLayoutRetryToken) current() bool {
368- if t.session == nil || t.tab == nil || len(t.members) == 0 {
369- return false
370- }
371- for i := range t.members {
372- member := &t.members[i]
373- if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrentLocked(member) {
374- return false
375- }
376- }
377- return true
378-}
379-
380-// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
381-// worker is deduplicated, derives cancellation from the tab lifecycle, and
382-// suppresses repeat degradation notices after the accepted initial failure.
383-func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab, failed ...[]resizeMember) bool {
384- if d.clock == nil || tb == nil || tb.ctx == nil {
385- return false
386- }
387- var members []resizeMember
388- if len(failed) != 0 {
389- members = failed[0]
390- } else {
391- tb.mu.Lock()
392- plan := prepareTabLayoutLocked(sess, tb)
393- tb.mu.Unlock()
394- for i := range plan.members {
395- member := plan.members[i]
396- member.pane.mu.Lock()
397- pending := member.pane.resizeRetry
398- member.pane.mu.Unlock()
399- if pending {
400- members = append(members, member)
401- }
402- }
403- }
404- token := newAcceptedTabLayoutRetryToken(sess, tb, members)
405- var ctx context.Context
406- var cancel context.CancelFunc
407- start := false
408- if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRetrySchedule, func() {
409- tb.layoutRetryMu.Lock()
410- defer tb.layoutRetryMu.Unlock()
411- if tb.layoutRetryRunning {
412- return
413- }
414- ctx, cancel = context.WithCancel(tb.ctx)
415- tb.layoutRetryRunning = true
416- tb.layoutRetryCancel = cancel
417- start = true
418- }) {
419- return false
420- }
421- if !start {
422- return true
423- }
424-
425- go func() {
426- defer func() {
427- cancel()
428- tb.layoutRetryMu.Lock()
429- tb.layoutRetryRunning = false
430- tb.layoutRetryCancel = nil
431- tb.layoutRetryMu.Unlock()
432- }()
433- for range maxAcceptedTabLayoutRetries {
434- if !token.current() {
435- return
436- }
437- timer := d.clock.NewTimer(minOutputRenderDeadline)
438- if timer == nil {
439- return
440- }
441- timerC := timer.C()
442- if timerC == nil {
443- timer.Stop()
444- return
445- }
446- select {
447- case <-ctx.Done():
448- timer.Stop()
449- return
450- case <-timerC:
451- }
452- if ctx.Err() != nil || !token.current() {
453- return
454- }
455- result, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool {
456- return ctx.Err() == nil && token.current()
457- })
458- if !ok || !token.current() {
459- return
460- }
461- if len(result.failed) == 0 {
462- return
463- }
464- token = newAcceptedTabLayoutRetryToken(sess, tb, result.failed)
465- if !token.current() {
466- return
467- }
468- if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
469- markSnapshotDirty(sess)
470- }) {
471- return
472- }
473- sess.mu.Lock()
474- ac := sess.client
475- sess.mu.Unlock()
476- if ac != nil && !d.publishResizeOwnerInvalidation(result.members, sess, ac, nil, 0,
477- renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
478- return
479- }
480- }
481- }()
482- return true
483-}
484-
485 // replayResizePending completes a pane's apply epoch. It keeps the gate set350 // replayResizePending completes a pane's apply epoch. It keeps the gate set
486 // while each buffered batch goes through the normal VT path, so reads arriving351 // while each buffered batch goes through the normal VT path, so reads arriving
487 // during replay join the same ordered stream. Failure deliberately omits the352 // during replay join the same ordered stream. Failure deliberately omits the
@@ -751,301 +616,6 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
751 }616 }
752 }617 }
753 618
754-type resizeCommitPublication struct {
755- members []resizeMember
756- session *session
757- attachment *attachedClient
758- lease *attachmentLease
759- epoch uint64
760- role attachmentRoleToken
761- coordinator *renderCoordinator
762- observer ports.RuntimeObserver
763- mark ports.RuntimeMark
764-}
765-
766-func (p resizeCommitPublication) current() bool {
767- if !resizeMembersOwnerCurrent(p.members) {
768- return false
769- }
770- if p.attachment == nil {
771- return true
772- }
773- if p.role.effect == nil || p.role.effect.ended.Load() || p.role.sess != p.session ||
774- p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
775- p.role.generation != p.attachment.roleGeneration.Load() ||
776- !p.attachment.transportSnapshotCurrent(p.role.transport) ||
777- p.attachment.currentSession() != p.session || p.coordinator == nil {
778- return false
779- }
780- p.session.mu.Lock()
781- active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive
782- p.session.mu.Unlock()
783- return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
784-}
785-
786-// emit performs observer work only after every resize-owner fence and
787-// attachment sendMu are released. The generation-bound publication is checked
788-// once more so a move that wins after geometry publication but before observer
789-// admission suppresses stale source telemetry.
790-func (p resizeCommitPublication) emit() bool {
791- if !p.current() {
792- return false
793- }
794- if p.observer != nil {
795- p.observer.ObserveRuntime(p.mark)
796- }
797- return true
798-}
799-
800-// publishResizeCommit linearizes attachment geometry and an immutable telemetry
801-// reservation with pane ownership. sendMu is acquired before resize fences, so
802-// the canonical rule never acquires an attachment lock while a pane fence is
803-// held. Observer callbacks run only after both lock families are released.
804-func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
805- d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
806- if ac != nil {
807- ac.sendMu.Lock()
808- if d.afterResizeCommitSendLocked != nil {
809- d.afterResizeCommitSendLocked()
810- }
811- }
812- fences := acquireResizeOwnerPostEffectFences(members)
813- if fences == nil {
814- if ac != nil {
815- ac.sendMu.Unlock()
816- }
817- return false
818- }
819- publication := resizeCommitPublication{
820- members: append([]resizeMember(nil), members...),
821- session: sess,
822- attachment: ac,
823- lease: lease,
824- epoch: epoch,
825- observer: d.runtimeObserver,
826- mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
827- }
828- if ticket != nil {
829- publication.role = ticket.roleToken()
830- publication.coordinator = sess.renderCoordinator()
831- }
832- if !publication.current() {
833- fences.Release()
834- if ac != nil {
835- ac.sendMu.Unlock()
836- }
837- return false
838- }
839- if ac != nil {
840- ac.size = size
841- }
842- fences.Release()
843- if ac != nil {
844- ac.sendMu.Unlock()
845- }
846- return publication.emit()
847-}
848-
849-func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
850- ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
851- if !admitted {
852- return false
853- }
854- defer ticket.End()
855- rc := sess.renderCoordinator()
856- if rc == nil {
857- return false
858- }
859- snap := rc.resizeSnapshot()
860- current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }
861- if !current() {
862- return false
863- }
864- d.exitCopyMode(ac)
865- result, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
866- return rc.resizeCurrentForLease(epoch, ac, lease, true)
867- })
868- if !ok {
869- return false
870- }
871- if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
872- markSnapshotDirty(sess)
873- }) {
874- return false
875- }
876- if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) {
877- return false
878- }
879- if len(result.failed) != 0 {
880- var retry *resizeRetryReservation
881- if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() {
882- retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
883- d.retryResizeMembers(sess, ac, lease, epoch, result.failed)
884- })
885- }) || retry == nil {
886- return false
887- }
888- retry.finish()
889- }
890- d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
891- if !d.publishResizeOwnerInvalidation(result.members, sess, ac, lease, epoch,
892- renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
893- return false
894- }
895- // The resize debounce has already elapsed. Consume this sticky reset now;
896- // fire preserves ACK and synchronized-output gates rather than scheduling a
897- // second urgent deadline.
898- rc.fireCurrent(false)
899- return true
900-}
901-
902-// retryResizeMembers retries failed committed members through a freshly
903-// prepared tab transaction. The captured members identify retry candidates
904-// only: their rectangles must never cross this delayed boundary, because any
905-// later layout mutation may have changed their geometry or removed them.
906-func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
907- ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
908- if !admitted {
909- return
910- }
911- defer ticket.End()
912- rc := sess.renderCoordinator()
913- if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
914- return
915- }
916-
917- // Retain tab order for deterministic external PTY ordering while collapsing
918- // several failed members from the same tab into one canonical transaction.
919- tabs := make([]*tab, 0, len(members))
920- seen := make(map[*tab]struct{}, len(members))
921- for _, member := range members {
922- if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) {
923- continue
924- }
925- if _, ok := seen[member.tab]; ok {
926- continue
927- }
928- seen[member.tab] = struct{}{}
929- tabs = append(tabs, member.tab)
930- }
931-
932- failed := make([]resizeMember, 0, len(members))
933- succeeded := false
934- for _, tb := range tabs {
935- if !rc.retryCurrentForLease(epoch, ac, lease) {
936- return
937- }
938- // A later accepted layout transaction clears resizeRetry. In that case
939- // this delayed callback has no remaining work and, importantly, must not
940- // replay the obsolete rectangle it captured before the mutation.
941- retryPending := false
942- tb.mu.Lock()
943- for _, member := range members {
944- if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
945- continue
946- }
947- member.pane.mu.Lock()
948- retryPending = retryPending || member.pane.resizeRetry
949- member.pane.mu.Unlock()
950- }
951- tb.mu.Unlock()
952- if !retryPending {
953- continue
954- }
955-
956- // applyTabLayoutTransaction captures the current generation, pane
957- // pointers, and solved rectangles, and validates them again before any
958- // VT/rectangle publication. It also preserves the resize gate and
959- // degradation notice behavior for another failed external attempt.
960- result, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
961- return rc.retryCurrentForLease(epoch, ac, lease)
962- })
963- if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
964- return
965- }
966- failed = append(failed, result.failed...)
967- // A retry success is a formerly failed, still-current target whose
968- // canonical apply cleared its retry bit. A collapsed or removed target
969- // is neither a retry completion nor a reason to publish a reset.
970- tb.mu.Lock()
971- for _, member := range members {
972- if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
973- continue
974- }
975- member.pane.mu.Lock()
976- succeeded = succeeded || !member.pane.resizeRetry
977- member.pane.mu.Unlock()
978- }
979- tb.mu.Unlock()
980- }
981- for _, member := range members {
982- if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrentLocked(&member) {
983- continue
984- }
985- if !rc.retryCurrentForLease(epoch, ac, lease) {
986- return
987- }
988- // Floating panes are outside tb.panes. Validate the exact accepted slot
989- // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
990- // the same pointer/generation check around external PTY.Resize.
991- member.tab.mu.Lock()
992- currentSlot := member.tab.floating.state == floatingVisible &&
993- member.tab.floating.generation == member.floatingGeneration &&
994- member.tab.floating.pane == member.pane &&
995- resizeMemberOwnerCurrentLocked(&member)
996- retryPending := false
997- if currentSlot {
998- member.pane.mu.Lock()
999- retryPending = member.pane.resizeRetry
1000- member.pane.mu.Unlock()
1001- }
1002- member.tab.mu.Unlock()
1003- if !currentSlot || !retryPending {
1004- continue
1005- }
1006- result, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
1007- return rc.retryCurrentForLease(epoch, ac, lease)
1008- }, &member)
1009- if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
1010- return
1011- }
1012- failed = append(failed, result.failed...)
1013- member.tab.mu.Lock()
1014- stillCurrent := member.tab.floating.state == floatingVisible &&
1015- member.tab.floating.generation == member.floatingGeneration &&
1016- member.tab.floating.pane == member.pane &&
1017- resizeMemberOwnerCurrentLocked(&member)
1018- member.tab.mu.Unlock()
1019- if stillCurrent && len(result.failed) == 0 {
1020- member.pane.mu.Lock()
1021- succeeded = succeeded || !member.pane.resizeRetry
1022- member.pane.mu.Unlock()
1023- }
1024- }
1025- if len(failed) != 0 {
1026- var retry *resizeRetryReservation
1027- if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() {
1028- retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
1029- d.retryResizeMembers(sess, ac, lease, epoch, failed)
1030- })
1031- }) || retry == nil {
1032- return
1033- }
1034- retry.finish()
1035- }
1036- if succeeded {
1037- // Retry completion changes VT state after the original layout commit.
1038- // Keep a named session's eventual snapshot generation aligned with it.
1039- if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() {
1040- markSnapshotDirty(sess)
1041- }) {
1042- return
1043- }
1044- d.publishResizeOwnerInvalidation(members, sess, ac, lease, 0,
1045- renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
1046- }
1047-}
1048-
1049 // requestTransactionalResize reports reset invalidation completion for immediate619 // requestTransactionalResize reports reset invalidation completion for immediate
1050 // attached requests, and coordinator schedule acceptance for async requests.620 // attached requests, and coordinator schedule acceptance for async requests.
1051 // Headless requests have no reset invalidation and report geometry completion.621 // Headless requests have no reset invalidation and report geometry completion.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close