detached 74d1510

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
261261 assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second")
262262 }
263263
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+
264294 func TestIntegration_AttachFirstOutput(t *testing.T) {
265295 sz := domain.Size{Cols: 80, Rows: 24}
266296 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
624624 clock: d.clock,
625625 observer: d.runtimeObserver,
626626 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+ }
631645 }
632646 },
633647 ackReady: func() bool {
@@ -721,6 +735,21 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
721735 if tb == nil {
722736 return
723737 }
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+ }
724753 tb.mu.Lock()
725754 wsz := tb.size
726755 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 this627+ // 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 return736 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.size754 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 {
298298 target := request.target
299299 switch request.kind {
300300 case daemonActionCreateTab:
301- return a.d.createTab(target.session, request.viewport)
301+ return a.d.createTabForAttachment(target.session, target.attachment, request.viewport)
302302 case daemonActionCloseTab:
303303 return a.d.closeTab(target.session, target.tab, true)
304304 case daemonActionSplitPane:
@@ -298,7 +298,7 @@ func (a daemonActions) Run(request daemonActionRequest) error {
298 target := request.target298 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 {
107107
108108 // invalidate publishes a coordinator-owned producer transition. Callers that
109109 // 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)
112112 }
113113
114114 // invalidateForAttachment publishes only while source is still the bound
@@ -747,12 +747,17 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
747747 // compositor; attached sessions always schedule through their coordinator.
748748 func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
749749 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) {
751752 return
752753 }
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.
755757 if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry {
758+ if rc.invalidate(invalidation) {
759+ return
760+ }
756761 d.paint(entry, ac, reset, nil)
757762 }
758763 return
@@ -107,8 +107,8 @@ func (c *renderCoordinator) detachSyncTimersLocked() []*timerToken {
107 107
108 // invalidate publishes a coordinator-owned producer transition. Callers that108 // 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 bound114 // 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 return752 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 return763 return
M internal/usecase/daemon/session.go +7 -0
@@ -512,6 +512,10 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
512512 }
513513
514514 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 {
515519 sess.mu.Lock()
516520 name := sess.name
517521 cwd := sess.cwd
@@ -572,6 +576,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
572576 if !sess.ephemeral {
573577 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
574578 }
579+ if ac != nil {
580+ sess.activateAttachmentViewLocked(ac, tabIndex)
581+ }
575582 sess.mu.Unlock()
576583 d.mu.Unlock()
577584 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.name520 name := sess.name
517 cwd := sess.cwd521 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)

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close