fix(daemon): fan out shared output to attachments
- commit
- 74d1510bf995a0bcf7a0d2d78b313768341849f5
- author
- brice · · 2026-08-03 04:00 UTC
- committer
- brice · · 2026-08-03 20:53 UTC
- parent
- 5376d8b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +81 -10
M internal/app/daemon_integration_test.go +30 -0
| @@ -261,6 +261,36 @@ func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) { | |||
| 261 | 261 | assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second") | |
| 262 | 262 | } | |
| 263 | 263 | ||
| 264 | + | func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T) { | |
| 265 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 266 | + | dir, _ := startDaemon(t) | |
| 267 | + | firstShell := shellFixture(t, "first") | |
| 268 | + | secondShell := shellFixture(t, "second") | |
| 269 | + | firstEnv := []string{ | |
| 270 | + | "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first", | |
| 271 | + | "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client", | |
| 272 | + | } | |
| 273 | + | tr1, p1 := attachWithEnvironment(t, dir, ports.IntentNew, "environment", sz, firstEnv) | |
| 274 | + | defer func() { _ = tr1.Close() }() | |
| 275 | + | awaitText(t, p1, sz, "SHELL_COMMAND=first") | |
| 276 | + | assertChildEnvironment(t, tr1, p1, sz, "first", firstShell, "/run/first", "wayland-first") | |
| 277 | + | ||
| 278 | + | secondEnv := []string{ | |
| 279 | + | "VEV_TEST_ENV=second", "SHELL=" + secondShell, "XDG_RUNTIME_DIR=/run/second", "WAYLAND_DISPLAY=wayland-second", | |
| 280 | + | "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client", | |
| 281 | + | } | |
| 282 | + | tr2, p2 := attachWithEnvironment(t, dir, ports.IntentAttach, "environment", sz, secondEnv) | |
| 283 | + | defer func() { _ = tr2.Close() }() | |
| 284 | + | assertChildEnvironment(t, tr2, p2, sz, "first", firstShell, "/run/first", "wayland-first") | |
| 285 | + | ||
| 286 | + | // The second attachment mutates shared tab state. Its newly opened PTY must | |
| 287 | + | // also be rendered to that attachment, not only to the coordinator primary. | |
| 288 | + | require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1b ")})})) | |
| 289 | + | awaitText(t, p2, sz, "Commands") | |
| 290 | + | require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})})) | |
| 291 | + | awaitText(t, p2, sz, "SHELL_COMMAND=second") | |
| 292 | + | } | |
| 293 | + | ||
| 264 | 294 | func TestIntegration_AttachFirstOutput(t *testing.T) { | |
| 265 | 295 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 266 | 296 | dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"})) | |
| @@ -261,6 +261,36 @@ func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) { | |||||
| 261 | assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second") | 261 | assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second") | ||
| 262 | } | 262 | } | ||
| 263 | 263 | ||||
| 264 | + | func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T) { | |||
| 265 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 266 | + | dir, _ := startDaemon(t) | |||
| 267 | + | firstShell := shellFixture(t, "first") | |||
| 268 | + | secondShell := shellFixture(t, "second") | |||
| 269 | + | firstEnv := []string{ | |||
| 270 | + | "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first", | |||
| 271 | + | "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client", | |||
| 272 | + | } | |||
| 273 | + | tr1, p1 := attachWithEnvironment(t, dir, ports.IntentNew, "environment", sz, firstEnv) | |||
| 274 | + | defer func() { _ = tr1.Close() }() | |||
| 275 | + | awaitText(t, p1, sz, "SHELL_COMMAND=first") | |||
| 276 | + | assertChildEnvironment(t, tr1, p1, sz, "first", firstShell, "/run/first", "wayland-first") | |||
| 277 | + | ||||
| 278 | + | secondEnv := []string{ | |||
| 279 | + | "VEV_TEST_ENV=second", "SHELL=" + secondShell, "XDG_RUNTIME_DIR=/run/second", "WAYLAND_DISPLAY=wayland-second", | |||
| 280 | + | "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client", | |||
| 281 | + | } | |||
| 282 | + | tr2, p2 := attachWithEnvironment(t, dir, ports.IntentAttach, "environment", sz, secondEnv) | |||
| 283 | + | defer func() { _ = tr2.Close() }() | |||
| 284 | + | assertChildEnvironment(t, tr2, p2, sz, "first", firstShell, "/run/first", "wayland-first") | |||
| 285 | + | ||||
| 286 | + | // The second attachment mutates shared tab state. Its newly opened PTY must | |||
| 287 | + | // also be rendered to that attachment, not only to the coordinator primary. | |||
| 288 | + | require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1b ")})})) | |||
| 289 | + | awaitText(t, p2, sz, "Commands") | |||
| 290 | + | require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})})) | |||
| 291 | + | awaitText(t, p2, sz, "SHELL_COMMAND=second") | |||
| 292 | + | } | |||
| 293 | + | ||||
| 264 | func TestIntegration_AttachFirstOutput(t *testing.T) { | 294 | func TestIntegration_AttachFirstOutput(t *testing.T) { | ||
| 265 | sz := domain.Size{Cols: 80, Rows: 24} | 295 | sz := domain.Size{Cols: 80, Rows: 24} | ||
| 266 | dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"})) | 296 | dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"})) | ||
M internal/usecase/daemon/client.go +33 -4
| @@ -624,10 +624,24 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess | |||
| 624 | 624 | clock: d.clock, | |
| 625 | 625 | observer: d.runtimeObserver, | |
| 626 | 626 | wake: func(w renderWake) { | |
| 627 | - | // The wake retains the exact attachment lease. paint repeats this | |
| 628 | - | // identity check under sendMu before composing any snapshot. | |
| 629 | - | if w.lease != nil && rc.wakeCurrent(w) { | |
| 630 | - | d.paint(entry, w.lease.attachment, w.reset, w.lease) | |
| 627 | + | // The wake retains the exact primary lease. Paint the primary first, | |
| 628 | + | // then fan the same shared-session state out to every live attachment | |
| 629 | + | // using each attachment's own output stream and renderer shadow. | |
| 630 | + | if w.lease == nil || !rc.wakeCurrent(w) { | |
| 631 | + | return | |
| 632 | + | } | |
| 633 | + | primary := w.lease.attachment | |
| 634 | + | d.paint(entry, primary, w.reset, w.lease) | |
| 635 | + | if !rc.wakeCurrent(w) { | |
| 636 | + | return | |
| 637 | + | } | |
| 638 | + | attachments := snapshotAttachmentSession(entry) | |
| 639 | + | for _, attachment := range attachments { | |
| 640 | + | if attachment != primary { | |
| 641 | + | // Pane damage is session-shared and the primary capture consumes it. | |
| 642 | + | // ponytail: secondary full redraws avoid per-attachment damage tracking; add attachment-scoped damage when fanout cost matters. | |
| 643 | + | d.paint(entry, attachment, true, nil) | |
| 644 | + | } | |
| 631 | 645 | } | |
| 632 | 646 | }, | |
| 633 | 647 | ackReady: func() bool { | |
| @@ -721,6 +735,21 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||
| 721 | 735 | if tb == nil { | |
| 722 | 736 | return | |
| 723 | 737 | } | |
| 738 | + | // The coordinator lease is only the primary render lifecycle. A secondary | |
| 739 | + | // attachment must paint directly: entering the nil-lease resize/activation | |
| 740 | + | // path would hand the session coordinator to it and strand the first client. | |
| 741 | + | secondary := false | |
| 742 | + | if lease == nil { | |
| 743 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 744 | + | rc.mu.Lock() | |
| 745 | + | secondary = rc.lease != nil && rc.lease.active && rc.lease.attachment != ac | |
| 746 | + | rc.mu.Unlock() | |
| 747 | + | } | |
| 748 | + | } | |
| 749 | + | if secondary { | |
| 750 | + | d.paint(sess, ac, true, nil) | |
| 751 | + | return | |
| 752 | + | } | |
| 724 | 753 | tb.mu.Lock() | |
| 725 | 754 | wsz := tb.size | |
| 726 | 755 | tb.mu.Unlock() | |
| @@ -624,10 +624,24 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess | |||||
| 624 | clock: d.clock, | 624 | clock: d.clock, | ||
| 625 | observer: d.runtimeObserver, | 625 | observer: d.runtimeObserver, | ||
| 626 | wake: func(w renderWake) { | 626 | wake: func(w renderWake) { | ||
| 627 | - | // The wake retains the exact attachment lease. paint repeats this | 627 | + | // The wake retains the exact primary lease. Paint the primary first, |
| 628 | - | // identity check under sendMu before composing any snapshot. | 628 | + | // then fan the same shared-session state out to every live attachment |
| 629 | - | if w.lease != nil && rc.wakeCurrent(w) { | 629 | + | // using each attachment's own output stream and renderer shadow. |
| 630 | - | d.paint(entry, w.lease.attachment, w.reset, w.lease) | 630 | + | if w.lease == nil || !rc.wakeCurrent(w) { |
| 631 | + | return | |||
| 632 | + | } | |||
| 633 | + | primary := w.lease.attachment | |||
| 634 | + | d.paint(entry, primary, w.reset, w.lease) | |||
| 635 | + | if !rc.wakeCurrent(w) { | |||
| 636 | + | return | |||
| 637 | + | } | |||
| 638 | + | attachments := snapshotAttachmentSession(entry) | |||
| 639 | + | for _, attachment := range attachments { | |||
| 640 | + | if attachment != primary { | |||
| 641 | + | // Pane damage is session-shared and the primary capture consumes it. | |||
| 642 | + | // ponytail: secondary full redraws avoid per-attachment damage tracking; add attachment-scoped damage when fanout cost matters. | |||
| 643 | + | d.paint(entry, attachment, true, nil) | |||
| 644 | + | } | |||
| 631 | } | 645 | } | ||
| 632 | }, | 646 | }, | ||
| 633 | ackReady: func() bool { | 647 | ackReady: func() bool { | ||
| @@ -721,6 +735,21 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||||
| 721 | if tb == nil { | 735 | if tb == nil { | ||
| 722 | return | 736 | return | ||
| 723 | } | 737 | } | ||
| 738 | + | // The coordinator lease is only the primary render lifecycle. A secondary | |||
| 739 | + | // attachment must paint directly: entering the nil-lease resize/activation | |||
| 740 | + | // path would hand the session coordinator to it and strand the first client. | |||
| 741 | + | secondary := false | |||
| 742 | + | if lease == nil { | |||
| 743 | + | if rc := sess.renderCoordinator(); rc != nil { | |||
| 744 | + | rc.mu.Lock() | |||
| 745 | + | secondary = rc.lease != nil && rc.lease.active && rc.lease.attachment != ac | |||
| 746 | + | rc.mu.Unlock() | |||
| 747 | + | } | |||
| 748 | + | } | |||
| 749 | + | if secondary { | |||
| 750 | + | d.paint(sess, ac, true, nil) | |||
| 751 | + | return | |||
| 752 | + | } | |||
| 724 | tb.mu.Lock() | 753 | tb.mu.Lock() | ||
| 725 | wsz := tb.size | 754 | wsz := tb.size | ||
| 726 | tb.mu.Unlock() | 755 | tb.mu.Unlock() | ||
M internal/usecase/daemon/control.go +1 -1
| @@ -298,7 +298,7 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 298 | 298 | target := request.target | |
| 299 | 299 | switch request.kind { | |
| 300 | 300 | case daemonActionCreateTab: | |
| 301 | - | return a.d.createTab(target.session, request.viewport) | |
| 301 | + | return a.d.createTabForAttachment(target.session, target.attachment, request.viewport) | |
| 302 | 302 | case daemonActionCloseTab: | |
| 303 | 303 | return a.d.closeTab(target.session, target.tab, true) | |
| 304 | 304 | case daemonActionSplitPane: | |
| @@ -298,7 +298,7 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||||
| 298 | target := request.target | 298 | target := request.target | ||
| 299 | switch request.kind { | 299 | switch request.kind { | ||
| 300 | case daemonActionCreateTab: | 300 | case daemonActionCreateTab: | ||
| 301 | - | return a.d.createTab(target.session, request.viewport) | 301 | + | return a.d.createTabForAttachment(target.session, target.attachment, request.viewport) |
| 302 | case daemonActionCloseTab: | 302 | case daemonActionCloseTab: | ||
| 303 | return a.d.closeTab(target.session, target.tab, true) | 303 | return a.d.closeTab(target.session, target.tab, true) | ||
| 304 | case daemonActionSplitPane: | 304 | case daemonActionSplitPane: | ||
M internal/usecase/daemon/render_coordinator.go +10 -5
| @@ -107,8 +107,8 @@ func (c *renderCoordinator) detachSyncTimersLocked() []*timerToken { | |||
| 107 | 107 | ||
| 108 | 108 | // invalidate publishes a coordinator-owned producer transition. Callers that | |
| 109 | 109 | // carry an attachment identity must use invalidateForAttachment instead. | |
| 110 | - | func (c *renderCoordinator) invalidate(inv renderInvalidation) { | |
| 111 | - | c.invalidateForAttachment(nil, inv) | |
| 110 | + | func (c *renderCoordinator) invalidate(inv renderInvalidation) bool { | |
| 111 | + | return c.invalidateForAttachment(nil, inv) | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | 114 | // invalidateForAttachment publishes only while source is still the bound | |
| @@ -747,12 +747,17 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() } | |||
| 747 | 747 | // compositor; attached sessions always schedule through their coordinator. | |
| 748 | 748 | func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | |
| 749 | 749 | if rc := attachmentRenderCoordinator(entry); rc != nil { | |
| 750 | - | if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) { | |
| 750 | + | invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer} | |
| 751 | + | if rc.invalidateForAttachment(ac, invalidation) { | |
| 751 | 752 | return | |
| 752 | 753 | } | |
| 753 | - | // Secondary attachments do not borrow the coordinator's primary lease; | |
| 754 | - | // paint their own attachment-owned view after membership validation. | |
| 754 | + | // Secondary attachments do not borrow the coordinator's primary lease. | |
| 755 | + | // Their shared mutation still schedules the session wake, whose fanout | |
| 756 | + | // paints every live attachment with its own output state. | |
| 755 | 757 | if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry { | |
| 758 | + | if rc.invalidate(invalidation) { | |
| 759 | + | return | |
| 760 | + | } | |
| 756 | 761 | d.paint(entry, ac, reset, nil) | |
| 757 | 762 | } | |
| 758 | 763 | return | |
| @@ -107,8 +107,8 @@ func (c *renderCoordinator) detachSyncTimersLocked() []*timerToken { | |||||
| 107 | 107 | ||||
| 108 | // invalidate publishes a coordinator-owned producer transition. Callers that | 108 | // invalidate publishes a coordinator-owned producer transition. Callers that | ||
| 109 | // carry an attachment identity must use invalidateForAttachment instead. | 109 | // carry an attachment identity must use invalidateForAttachment instead. | ||
| 110 | - | func (c *renderCoordinator) invalidate(inv renderInvalidation) { | 110 | + | func (c *renderCoordinator) invalidate(inv renderInvalidation) bool { |
| 111 | - | c.invalidateForAttachment(nil, inv) | 111 | + | return c.invalidateForAttachment(nil, inv) |
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | // invalidateForAttachment publishes only while source is still the bound | 114 | // invalidateForAttachment publishes only while source is still the bound | ||
| @@ -747,12 +747,17 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() } | |||||
| 747 | // compositor; attached sessions always schedule through their coordinator. | 747 | // compositor; attached sessions always schedule through their coordinator. | ||
| 748 | func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | 748 | func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | ||
| 749 | if rc := attachmentRenderCoordinator(entry); rc != nil { | 749 | if rc := attachmentRenderCoordinator(entry); rc != nil { | ||
| 750 | - | if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) { | 750 | + | invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer} |
| 751 | + | if rc.invalidateForAttachment(ac, invalidation) { | |||
| 751 | return | 752 | return | ||
| 752 | } | 753 | } | ||
| 753 | - | // Secondary attachments do not borrow the coordinator's primary lease; | 754 | + | // Secondary attachments do not borrow the coordinator's primary lease. |
| 754 | - | // paint their own attachment-owned view after membership validation. | 755 | + | // Their shared mutation still schedules the session wake, whose fanout |
| 756 | + | // paints every live attachment with its own output state. | |||
| 755 | if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry { | 757 | if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry { | ||
| 758 | + | if rc.invalidate(invalidation) { | |||
| 759 | + | return | |||
| 760 | + | } | |||
| 756 | d.paint(entry, ac, reset, nil) | 761 | d.paint(entry, ac, reset, nil) | ||
| 757 | } | 762 | } | ||
| 758 | return | 763 | return | ||
M internal/usecase/daemon/session.go +7 -0
| @@ -512,6 +512,10 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||
| 512 | 512 | } | |
| 513 | 513 | ||
| 514 | 514 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |
| 515 | + | return d.createTabForAttachment(sess, nil, sz) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz domain.Size) error { | |
| 515 | 519 | sess.mu.Lock() | |
| 516 | 520 | name := sess.name | |
| 517 | 521 | cwd := sess.cwd | |
| @@ -572,6 +576,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 572 | 576 | if !sess.ephemeral { | |
| 573 | 577 | d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) | |
| 574 | 578 | } | |
| 579 | + | if ac != nil { | |
| 580 | + | sess.activateAttachmentViewLocked(ac, tabIndex) | |
| 581 | + | } | |
| 575 | 582 | sess.mu.Unlock() | |
| 576 | 583 | d.mu.Unlock() | |
| 577 | 584 | d.log.Info("tab created", "session", name, "tab", tabIndex) | |
| @@ -512,6 +512,10 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||||
| 512 | } | 512 | } | ||
| 513 | 513 | ||||
| 514 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | 514 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | ||
| 515 | + | return d.createTabForAttachment(sess, nil, sz) | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz domain.Size) error { | |||
| 515 | sess.mu.Lock() | 519 | sess.mu.Lock() | ||
| 516 | name := sess.name | 520 | name := sess.name | ||
| 517 | cwd := sess.cwd | 521 | cwd := sess.cwd | ||
| @@ -572,6 +576,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 572 | if !sess.ephemeral { | 576 | if !sess.ephemeral { | ||
| 573 | d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) | 577 | d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) | ||
| 574 | } | 578 | } | ||
| 579 | + | if ac != nil { | |||
| 580 | + | sess.activateAttachmentViewLocked(ac, tabIndex) | |||
| 581 | + | } | |||
| 575 | sess.mu.Unlock() | 582 | sess.mu.Unlock() | ||
| 576 | d.mu.Unlock() | 583 | d.mu.Unlock() | ||
| 577 | d.log.Info("tab created", "session", name, "tab", tabIndex) | 584 | d.log.Info("tab created", "session", name, "tab", tabIndex) | ||