test(daemon): cover adaptive and synchronized deadlines
- commit
- c1108a41792eec61e0d625bf122b339a9d349ff6
- author
- brice · · 2026-07-11 17:31 UTC
- committer
- brice · · 2026-07-11 17:32 UTC
- parent
- 4e6142e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +73 -0
M internal/usecase/daemon/render_coordinator_test.go +50 -0
| @@ -207,6 +207,56 @@ func TestRenderCoordinatorCoalescesWakesUnderOneDeadline(t *testing.T) { | |||
| 207 | 207 | } | |
| 208 | 208 | } | |
| 209 | 209 | ||
| 210 | + | func TestRenderCoordinatorAdaptsOutputDeadlineAfterBurstAndDecays(t *testing.T) { | |
| 211 | + | h := newCoordinatorHarness(t) | |
| 212 | + | ||
| 213 | + | // A burst establishes pressure without extending its already-armed first | |
| 214 | + | // deadline; the following output deadline grows to the 16ms ceiling. | |
| 215 | + | for range 9 { | |
| 216 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 217 | + | } | |
| 218 | + | burst := h.armedTimers(t) | |
| 219 | + | require.Len(t, burst, 1) | |
| 220 | + | require.Equal(t, minOutputRenderDeadline, burst[0].duration) | |
| 221 | + | burst[0].ch <- time.Time{} | |
| 222 | + | awaitWake(t, h.wakes) | |
| 223 | + | ||
| 224 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 225 | + | atCap := h.armedTimers(t) | |
| 226 | + | require.Len(t, atCap, 1) | |
| 227 | + | require.Equal(t, maxOutputRenderDeadline, atCap[0].duration, | |
| 228 | + | "burst pressure must be capped at the 16ms output deadline") | |
| 229 | + | atCap[0].ch <- time.Time{} | |
| 230 | + | awaitWake(t, h.wakes) | |
| 231 | + | ||
| 232 | + | // Quiet singleton batches decay pressure toward the 8ms floor. | |
| 233 | + | var previous = maxOutputRenderDeadline | |
| 234 | + | for range 8 { | |
| 235 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 236 | + | timers := h.armedTimers(t) | |
| 237 | + | require.Len(t, timers, 1) | |
| 238 | + | require.LessOrEqual(t, timers[0].duration, previous) | |
| 239 | + | require.GreaterOrEqual(t, timers[0].duration, minOutputRenderDeadline) | |
| 240 | + | previous = timers[0].duration | |
| 241 | + | timers[0].ch <- time.Time{} | |
| 242 | + | awaitWake(t, h.wakes) | |
| 243 | + | } | |
| 244 | + | require.Equal(t, minOutputRenderDeadline, previous) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) { | |
| 248 | + | h := newCoordinatorHarness(t) | |
| 249 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | |
| 250 | + | h.rc.invalidate(renderInvalidation{class: invalidateUrgent}) | |
| 251 | + | for range 32 { | |
| 252 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | |
| 253 | + | h.rc.invalidate(renderInvalidation{class: invalidateUrgent}) | |
| 254 | + | } | |
| 255 | + | timers := h.armedTimers(t) | |
| 256 | + | require.Len(t, timers, 2, "only the urgent promotion may replace the output timer") | |
| 257 | + | require.Equal(t, urgentRenderDeadline, timers[len(timers)-1].duration) | |
| 258 | + | } | |
| 259 | + | ||
| 210 | 260 | // --- ACK gating ---------------------------------------------------------------- | |
| 211 | 261 | ||
| 212 | 262 | func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) { | |
| @@ -207,6 +207,56 @@ func TestRenderCoordinatorCoalescesWakesUnderOneDeadline(t *testing.T) { | |||||
| 207 | } | 207 | } | ||
| 208 | } | 208 | } | ||
| 209 | 209 | ||||
| 210 | + | func TestRenderCoordinatorAdaptsOutputDeadlineAfterBurstAndDecays(t *testing.T) { | |||
| 211 | + | h := newCoordinatorHarness(t) | |||
| 212 | + | ||||
| 213 | + | // A burst establishes pressure without extending its already-armed first | |||
| 214 | + | // deadline; the following output deadline grows to the 16ms ceiling. | |||
| 215 | + | for range 9 { | |||
| 216 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |||
| 217 | + | } | |||
| 218 | + | burst := h.armedTimers(t) | |||
| 219 | + | require.Len(t, burst, 1) | |||
| 220 | + | require.Equal(t, minOutputRenderDeadline, burst[0].duration) | |||
| 221 | + | burst[0].ch <- time.Time{} | |||
| 222 | + | awaitWake(t, h.wakes) | |||
| 223 | + | ||||
| 224 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |||
| 225 | + | atCap := h.armedTimers(t) | |||
| 226 | + | require.Len(t, atCap, 1) | |||
| 227 | + | require.Equal(t, maxOutputRenderDeadline, atCap[0].duration, | |||
| 228 | + | "burst pressure must be capped at the 16ms output deadline") | |||
| 229 | + | atCap[0].ch <- time.Time{} | |||
| 230 | + | awaitWake(t, h.wakes) | |||
| 231 | + | ||||
| 232 | + | // Quiet singleton batches decay pressure toward the 8ms floor. | |||
| 233 | + | var previous = maxOutputRenderDeadline | |||
| 234 | + | for range 8 { | |||
| 235 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |||
| 236 | + | timers := h.armedTimers(t) | |||
| 237 | + | require.Len(t, timers, 1) | |||
| 238 | + | require.LessOrEqual(t, timers[0].duration, previous) | |||
| 239 | + | require.GreaterOrEqual(t, timers[0].duration, minOutputRenderDeadline) | |||
| 240 | + | previous = timers[0].duration | |||
| 241 | + | timers[0].ch <- time.Time{} | |||
| 242 | + | awaitWake(t, h.wakes) | |||
| 243 | + | } | |||
| 244 | + | require.Equal(t, minOutputRenderDeadline, previous) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) { | |||
| 248 | + | h := newCoordinatorHarness(t) | |||
| 249 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | |||
| 250 | + | h.rc.invalidate(renderInvalidation{class: invalidateUrgent}) | |||
| 251 | + | for range 32 { | |||
| 252 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | |||
| 253 | + | h.rc.invalidate(renderInvalidation{class: invalidateUrgent}) | |||
| 254 | + | } | |||
| 255 | + | timers := h.armedTimers(t) | |||
| 256 | + | require.Len(t, timers, 2, "only the urgent promotion may replace the output timer") | |||
| 257 | + | require.Equal(t, urgentRenderDeadline, timers[len(timers)-1].duration) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 210 | // --- ACK gating ---------------------------------------------------------------- | 260 | // --- ACK gating ---------------------------------------------------------------- | ||
| 211 | 261 | ||||
| 212 | func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) { | 262 | func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) { | ||
M internal/usecase/daemon/snapshot_test.go +23 -0
| @@ -213,6 +213,29 @@ func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) { | |||
| 213 | 213 | require.True(t, sess.snapDirty.Load()) | |
| 214 | 214 | } | |
| 215 | 215 | ||
| 216 | + | func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *testing.T) { | |
| 217 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 218 | + | p := chunkReadPTY(t, []byte("\x1b[?2026hcomplete batch\x1b[?2026l")) | |
| 219 | + | sctx, cancel := context.WithCancel(context.Background()) | |
| 220 | + | defer cancel() | |
| 221 | + | tb := newTestTabWithContext(p, sctx, cancel) | |
| 222 | + | sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, ctx: sctx, cancel: cancel} | |
| 223 | + | invs := make(chan renderInvalidation, 1) | |
| 224 | + | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 225 | + | sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |
| 226 | + | clock: clock, | |
| 227 | + | onInvalidate: func(inv renderInvalidation) { invs <- inv }, | |
| 228 | + | })) | |
| 229 | + | d.sessions[sess.id] = sess | |
| 230 | + | d.sessWg.Add(1) | |
| 231 | + | d.ptyReader(sess, tb, tb.focusedPane()) | |
| 232 | + | ||
| 233 | + | inv := awaitInvalidation(t, invs) | |
| 234 | + | require.Equal(t, invalidateUrgent, inv.class, "a complete same-read synchronized batch must flush urgently") | |
| 235 | + | timer := awaitScheduledTimer(t, clock) | |
| 236 | + | require.Equal(t, urgentRenderDeadline, timer.duration) | |
| 237 | + | } | |
| 238 | + | ||
| 216 | 239 | func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | |
| 217 | 240 | clock := newManualSnapshotClock() | |
| 218 | 241 | store := portsmocks.NewMockSnapshotStore(t) | |
| @@ -213,6 +213,29 @@ func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) { | |||||
| 213 | require.True(t, sess.snapDirty.Load()) | 213 | require.True(t, sess.snapDirty.Load()) | ||
| 214 | } | 214 | } | ||
| 215 | 215 | ||||
| 216 | + | func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *testing.T) { | |||
| 217 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 218 | + | p := chunkReadPTY(t, []byte("\x1b[?2026hcomplete batch\x1b[?2026l")) | |||
| 219 | + | sctx, cancel := context.WithCancel(context.Background()) | |||
| 220 | + | defer cancel() | |||
| 221 | + | tb := newTestTabWithContext(p, sctx, cancel) | |||
| 222 | + | sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, ctx: sctx, cancel: cancel} | |||
| 223 | + | invs := make(chan renderInvalidation, 1) | |||
| 224 | + | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |||
| 225 | + | sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |||
| 226 | + | clock: clock, | |||
| 227 | + | onInvalidate: func(inv renderInvalidation) { invs <- inv }, | |||
| 228 | + | })) | |||
| 229 | + | d.sessions[sess.id] = sess | |||
| 230 | + | d.sessWg.Add(1) | |||
| 231 | + | d.ptyReader(sess, tb, tb.focusedPane()) | |||
| 232 | + | ||||
| 233 | + | inv := awaitInvalidation(t, invs) | |||
| 234 | + | require.Equal(t, invalidateUrgent, inv.class, "a complete same-read synchronized batch must flush urgently") | |||
| 235 | + | timer := awaitScheduledTimer(t, clock) | |||
| 236 | + | require.Equal(t, urgentRenderDeadline, timer.duration) | |||
| 237 | + | } | |||
| 238 | + | ||||
| 216 | func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | 239 | func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | ||
| 217 | clock := newManualSnapshotClock() | 240 | clock := newManualSnapshotClock() | ||
| 218 | store := portsmocks.NewMockSnapshotStore(t) | 241 | store := portsmocks.NewMockSnapshotStore(t) | ||