refactor(daemon): decompose move orchestration
- commit
- 8f53268431b50abeaf159a42a24c6d68568a4195
- author
- brice · · 2026-07-28 12:41 UTC
- parent
- 5c07e9a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 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 | 660 | // detachIfCurrent clears the client iff ac is the current one, reporting | |
| 921 | 661 | ||
| 922 | 662 | func themeFromMessage(msg ports.Theme) themeui.Theme { | |
| @@ -1060,67 +800,6 @@ func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domai | |||
| 1060 | 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 | 803 | // killSession removes a session and tears down its resources. It is | |
| 1125 | 804 | // idempotent: only the caller that wins the registry delete acts. When the | |
| 1126 | 805 | // 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, reporting | 660 | // 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 is | 803 | // killSession removes a session and tears down its resources. It is | ||
| 1125 | // idempotent: only the caller that wins the registry delete acts. When the | 804 | // idempotent: only the caller that wins the registry delete acts. When the | ||
| 1126 | // registry empties it marks the daemon closing (atomically with the | 805 | // 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 ( | |||
| 14 | 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 | 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 | 19 | // enterPicker preserves the existing navigation entry point. Navigation always | |
| 69 | 20 | // publishes its model, including an empty one; only move entry can fail for a | |
| 70 | 21 | // missing destination. | |
| @@ -73,19 +24,6 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |||
| 73 | 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 | 27 | func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) { | |
| 90 | 28 | rt := ac.overlays | |
| 91 | 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 | 129 | func attentionSuffix(label string) string { | |
| 203 | 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 | 396 | func (d *Daemon) closePicker(ac *attachedClient) { | |
| 495 | 397 | ac.overlays.pickerMu.Lock() | |
| 496 | 398 | ac.overlays.picker = nil | |
| @@ -503,39 +405,6 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||
| 503 | 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 | 408 | func pickerTargetsEqual(left, right picker.Target) bool { | |
| 540 | 409 | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || | |
| 541 | 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 | 820 | 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 | 823 | func snapshotPickerPreview(tb *tab) picker.Preview { | |
| 991 | 824 | if tb == nil { | |
| 992 | 825 | 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 always | 19 | // enterPicker preserves the existing navigation entry point. Navigation always | ||
| 69 | // publishes its model, including an empty one; only move entry can fail for a | 20 | // 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.overlays | 28 | 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 = nil | 398 | 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 nil | 820 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | - | ||
| 6 | 4 | "github.com/bnema/vev/internal/domain" | |
| 7 | 5 | "github.com/bnema/vev/internal/ports" | |
| 8 | 6 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -349,139 +347,6 @@ func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool { | |||
| 349 | 347 | 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 | 350 | // replayResizePending completes a pane's apply epoch. It keeps the gate set | |
| 486 | 351 | // while each buffered batch goes through the normal VT path, so reads arriving | |
| 487 | 352 | // 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 | 619 | // requestTransactionalResize reports reset invalidation completion for immediate | |
| 1050 | 620 | // attached requests, and coordinator schedule acceptance for async requests. | |
| 1051 | 621 | // Headless requests have no reset invalidation and report geometry completion. | |
| @@ -1,8 +1,6 @@ | |||||
| 1 | package daemon | 1 | 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 true | 347 | 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 set | 350 | // 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 arriving | 351 | // while each buffered batch goes through the normal VT path, so reads arriving | ||
| 487 | // during replay join the same ordered stream. Failure deliberately omits the | 352 | // 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 immediate | 619 | // 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. | ||