Skip to content

Commit e0004b9

Browse files
kommandersimonklee
andauthored
Handle native render backpressure and failures (#1163)
- Retry feed-backed skipped frames once the feed becomes idle. - Coalesce render requests while waiting for feed readiness. - Avoid automatic retries for native failures. - Preserve paused, stopped, suspended, and continuous-rendering behavior. - Prevent split-footer failure retry loops and unresolved idle waits. --------- Co-authored-by: Simon Klee <hello@simonklee.dk>
1 parent 9ef4691 commit e0004b9

2 files changed

Lines changed: 507 additions & 21 deletions

File tree

packages/core/src/renderer.ts

Lines changed: 101 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)