@@ -955,6 +955,8 @@ export class CliRenderer extends EventEmitter implements RenderContext {
955955 private _detachFeed : ( ( ) => void ) | null = null
956956 private _detachFeedError : ( ( ) => void ) | null = null
957957 private feedIdleRenderScheduled = false
958+ private ordinaryFrameWaitingForFeed = false
959+ private ordinaryFrameWaitControlState : RendererControlState | null = null
958960
959961 public get controlState ( ) : RendererControlState {
960962 return this . _controlState
@@ -1411,32 +1413,90 @@ export class CliRenderer extends EventEmitter implements RenderContext {
14111413 this . feedIdleRenderScheduled = true
14121414 feed . idle ( ) . then ( ( ) => {
14131415 this . feedIdleRenderScheduled = false
1414- if ( this . _isDestroyed ) {
1416+ const ordinaryFrameWasWaiting = this . ordinaryFrameWaitingForFeed
1417+ const ordinaryFrameWaitControlState = this . ordinaryFrameWaitControlState
1418+ this . ordinaryFrameWaitingForFeed = false
1419+ this . ordinaryFrameWaitControlState = null
1420+ if (
1421+ this . _isDestroyed ||
1422+ ( ordinaryFrameWasWaiting &&
1423+ this . _controlState !== ordinaryFrameWaitControlState &&
1424+ ( this . _controlState === RendererControlState . EXPLICIT_PAUSED ||
1425+ this . _controlState === RendererControlState . EXPLICIT_STOPPED ||
1426+ this . _controlState === RendererControlState . EXPLICIT_SUSPENDED ) )
1427+ ) {
14151428 this . resolveIdleIfNeeded ( )
14161429 return
14171430 }
14181431
1419- if ( this . _isRunning ) {
1420- if ( ! this . renderTimeout && ! this . rendering ) {
1421- this . renderTimeout = this . clock . setTimeout ( ( ) => {
1422- this . renderTimeout = null
1423- this . loop ( )
1424- } , 0 )
1425- }
1426- return
1427- }
1428-
1429- this . requestRender ( )
1432+ this . scheduleRenderTimer ( )
14301433 this . resolveIdleIfNeeded ( )
14311434 } )
14321435 }
14331436
1437+ private handleNativeRenderRejection ( status : number ) : "retryable-skip" | "failed" {
1438+ if ( status === NATIVE_RENDER_STATUS_SKIPPED && this . _feed ) {
1439+ this . ordinaryFrameWaitingForFeed = true
1440+ this . ordinaryFrameWaitControlState = this . _controlState
1441+ this . scheduleRenderAfterFeedIdle ( )
1442+ return "retryable-skip"
1443+ }
1444+
1445+ if ( status === NATIVE_RENDER_STATUS_SKIPPED ) {
1446+ console . error ( "[CliRenderer] Native frame render unexpectedly skipped without a feed" )
1447+ return "failed"
1448+ }
1449+
1450+ return this . reportNativeRenderFailure ( )
1451+ }
1452+
1453+ private reportNativeRenderFailure ( ) : "failed" {
1454+ console . error ( "[CliRenderer] Native frame render failed; waiting for the next render request to force repaint" )
1455+ return "failed"
1456+ }
1457+
1458+ private scheduleRenderTimer ( ) : void {
1459+ if ( this . renderTimeout || this . _isDestroyed || this . _controlState === RendererControlState . EXPLICIT_SUSPENDED )
1460+ return
1461+
1462+ const now = this . normalizeClockTime ( this . clock . now ( ) , this . lastTime )
1463+ const elapsed = this . getElapsedMs ( now , this . lastTime )
1464+ const delay = Math . max ( this . minTargetFrameTime - elapsed , 0 )
1465+ this . renderTimeout = this . clock . setTimeout ( ( ) => {
1466+ this . renderTimeout = null
1467+ this . loop ( )
1468+ } , delay )
1469+ }
1470+
1471+ private scheduleRenderAfterBackpressure ( ) : void {
1472+ if ( this . _feed ) {
1473+ this . scheduleRenderAfterFeedIdle ( )
1474+ return
1475+ }
1476+
1477+ this . scheduleRenderTimer ( )
1478+ }
1479+
14341480 public requestRender ( ) {
14351481 if ( this . _controlState === RendererControlState . EXPLICIT_SUSPENDED ) {
14361482 return
14371483 }
14381484
1485+ // A skipped feed-backed frame already owns the next scheduling attempt through
1486+ // feed.idle(). Coalesce normal invalidations into that retry so split-footer
1487+ // output and UI updates cannot start competing render passes while the feed is busy.
1488+ if ( this . feedIdleRenderScheduled ) {
1489+ return
1490+ }
1491+
14391492 if ( this . _isRunning ) {
1493+ if ( ! this . rendering && ! this . renderTimeout && ! this . ordinaryFrameWaitingForFeed ) {
1494+ this . scheduleRenderTimer ( )
1495+ }
1496+ return
1497+ }
1498+
1499+ if ( this . ordinaryFrameWaitingForFeed ) {
14401500 return
14411501 }
14421502
@@ -2483,7 +2543,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
24832543 private flushPendingSplitCommits (
24842544 forceFooterRepaint : boolean = false ,
24852545 drainAll : boolean = false ,
2486- ) : "rendered" | "backpressured" {
2546+ ) : "rendered" | "backpressured" | "failed" {
24872547 // Drain only a bounded prefix so one JS render pass maps to one native frame.
24882548 // Remaining commits are intentionally left queued and rendered on subsequent
24892549 // ticks to avoid giant multi-thousand-cell frames that can flicker.
@@ -2492,6 +2552,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
24922552 const lastCommitIndex = commits . length - 1
24932553 let acceptedCommits = 0
24942554 let nativeBackpressured = false
2555+ let nativeFailed = false
24952556
24962557 for ( const [ index , commit ] of commits . entries ( ) ) {
24972558 // Force repaint only on the last commit in a frame. Repainting after every
@@ -2521,7 +2582,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
25212582 }
25222583
25232584 if ( nativeResult . status === NATIVE_RENDER_STATUS_FAILED ) {
2524- nativeBackpressured = true
2585+ nativeFailed = true
25252586 break
25262587 }
25272588
@@ -2535,6 +2596,10 @@ export class CliRenderer extends EventEmitter implements RenderContext {
25352596 this . externalOutputQueue . drop ( acceptedCommits )
25362597 }
25372598
2599+ if ( nativeFailed ) {
2600+ return this . reportNativeRenderFailure ( )
2601+ }
2602+
25382603 if ( nativeBackpressured ) {
25392604 this . scheduleRenderAfterFeedIdle ( )
25402605 return "backpressured"
@@ -2546,10 +2611,13 @@ export class CliRenderer extends EventEmitter implements RenderContext {
25462611 this . getSplitPinnedRenderOffset ( ) ,
25472612 forceFooterRepaint ,
25482613 )
2549- if ( nativeResult . status === NATIVE_RENDER_STATUS_SKIPPED || nativeResult . status === NATIVE_RENDER_STATUS_FAILED ) {
2614+ if ( nativeResult . status === NATIVE_RENDER_STATUS_SKIPPED ) {
25502615 this . scheduleRenderAfterFeedIdle ( )
25512616 return "backpressured"
25522617 }
2618+ if ( nativeResult . status === NATIVE_RENDER_STATUS_FAILED ) {
2619+ return this . reportNativeRenderFailure ( )
2620+ }
25532621 this . renderOffset = nativeResult . renderOffset
25542622 }
25552623
@@ -4290,6 +4358,11 @@ export class CliRenderer extends EventEmitter implements RenderContext {
42904358 this . lastFpsTime = this . lastTime
42914359 this . currentFps = 0
42924360
4361+ // Starting continuous mode must not bypass an existing feed-idle retry. Keep
4362+ // _isRunning true, but let the idle callback schedule the first loop once the
4363+ // feed is ready; a successful frame will then resume the normal cadence.
4364+ if ( this . feedIdleRenderScheduled ) return
4365+
42934366 this . loop ( )
42944367 }
42954368
@@ -4394,7 +4467,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
43944467 this . clock . clearTimeout ( this . renderTimeout ! )
43954468 this . renderTimeout = null
43964469 }
4397- } else if ( nativeStatus === "skipped " ) {
4470+ } else if ( nativeStatus === "blocked " ) {
43984471 const overallFrameTime = performance . now ( ) - overallStart
43994472
44004473 if ( this . _isRunning || this . immediateRerenderRequested ) {
@@ -4409,7 +4482,12 @@ export class CliRenderer extends EventEmitter implements RenderContext {
44094482 this . clock . clearTimeout ( this . renderTimeout ! )
44104483 this . renderTimeout = null
44114484 }
4412- } else {
4485+ } else if ( nativeStatus === "backpressured" ) {
4486+ this . scheduleRenderAfterBackpressure ( )
4487+ } else if ( nativeStatus === "retryable-skip" ) {
4488+ this . immediateRerenderRequested = false
4489+ this . renderTimeout = null
4490+ } else if ( nativeStatus === "failed" ) {
44134491 this . immediateRerenderRequested = false
44144492 this . renderTimeout = null
44154493 }
@@ -4428,7 +4506,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
44284506 this . loop ( )
44294507 }
44304508
4431- private renderNative ( ) : "rendered" | "backpressured " | "skipped " {
4509+ private renderNative ( ) : "rendered" | "retryable-skip " | "failed" | "blocked" | "backpressured " {
44324510 if ( this . renderingNative ) {
44334511 console . error ( "Rendering called concurrently" )
44344512 throw new Error ( "Rendering called concurrently" )
@@ -4438,7 +4516,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
44384516
44394517 try {
44404518 if ( this . isSplitCursorSeedFrameBlocked ( ) ) {
4441- return "skipped "
4519+ return "blocked "
44424520 }
44434521
44444522 if ( this . _splitHeight > 0 && this . _externalOutputMode === "capture-stdout" ) {
@@ -4453,6 +4531,9 @@ export class CliRenderer extends EventEmitter implements RenderContext {
44534531 if ( status === "backpressured" ) {
44544532 return "backpressured"
44554533 }
4534+ if ( status === "failed" ) {
4535+ return "failed"
4536+ }
44564537 this . forceFullRepaintRequested = false
44574538 this . pendingSplitFooterTransition = null
44584539 return "rendered"
@@ -4461,8 +4542,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
44614542 const force = this . forceFullRepaintRequested
44624543 const nativeStatus = this . lib . render ( this . rendererPtr , force )
44634544 if ( nativeStatus === NATIVE_RENDER_STATUS_SKIPPED || nativeStatus === NATIVE_RENDER_STATUS_FAILED ) {
4464- this . scheduleRenderAfterFeedIdle ( )
4465- return "backpressured"
4545+ return this . handleNativeRenderRejection ( nativeStatus )
44664546 }
44674547
44684548 this . forceFullRepaintRequested = false
0 commit comments