Compare commits

..
Author SHA1 Message Date
陈大猫andCodex db3a9e88e2 fix(terminal): cap WebGL contexts for visible split panes (Fixes #4128) (#4239)
The #4122 budget of 12 only released hidden panes, so 17+ visible
splits still crossed Chromium’s 16-context limit and the 50ms
rebuild avalanched into white panes until the DOM breaker tripped.

Refuse new visible contexts when visible holders fill the cap while
preserving recently hidden holders during the tab-flip grace period.
Cover acquisition limits, missed visibility syncs, and recovery at cap.

Co-authored-by: Codex <codex@openai.com>
2026-11-14 18:21:32 +08:00
陈大猫andCodex 65435c06f5 fix(x11): hide fd-bearing X11 extensions on byte-only forwards (Fixes #4200) (#4238)
Clear QueryExtension present for DRI3 and MIT-SHM on built-in X11 forwards so clients avoid SCM_RIGHTS replies that the SSH byte stream cannot transport. Preserve framing, byte order, sequence isolation and cookie rewriting.

Strip AppImage ARGV0 from local shell PTY environments so zsh preserves ssh argv[0] and Xorg recognizes system SSH forwarding. Add protocol and environment regression tests. Fixes #4200.

Co-authored-by: Codex <codex@openai.com>
2026-11-14 16:28:28 +08:00
4 changed files with 145 additions and 18 deletions

No files matched your search

@@ -89,6 +89,15 @@ test("a visible pane past the cap releases the least recently seen hidden pane",
assert.equal(budget.getActiveCount(), 3);
});
test("new visible panes are refused when visible holders fill the cap", () => {
const { budget } = setup(2);
const members = ["a", "b", "c", "d"].map((name) => new FakeMember(name));
assert.deepEqual(members.map((member) => budget.tryAcquire(member)), [true, true, false, false]);
assert.equal(budget.tryAcquire(members[0]), true, "an existing holder keeps its slot");
assert.equal(budget.getActiveCount(), 2);
assert.deepEqual(members.map((member) => member.releases), [0, 0, 0, 0]);
});
test("recently hidden panes are released only once they stay hidden long enough", () => {
const { clock, budget } = setup(2);
const [a, b] = ["a", "b"].map((name) => new FakeMember(name));
@@ -101,6 +110,8 @@ test("recently hidden panes are released only once they stay hidden long enough"
budget.tryAcquire(new FakeMember("shown"));
assert.equal(a.releases, 0, "a quick tab flip must not lose the renderer");
assert.equal(budget.getActiveCount(), 3);
assert.equal(budget.tryAcquire(new FakeMember("overflow")), false);
assert.equal(budget.getActiveCount(), 3, "one hidden holder allows only one extra context");
clock.advance(2_999);
assert.equal(a.releases, 0);
@@ -110,26 +121,33 @@ test("recently hidden panes are released only once they stay hidden long enough"
assert.equal(clock.pendingTimers, 0);
});
test("a pane hidden after the budget went over is released once eligible", () => {
test("a pane hidden after a grace overshoot is released once eligible", () => {
const { clock, budget } = setup(2);
const [a, b, c] = ["a", "b", "c"].map((name) => new FakeMember(name));
budget.tryAcquire(a);
budget.tryAcquire(b);
budget.tryAcquire(c);
assert.equal(budget.getActiveCount(), 3, "three visible panes all keep WebGL");
assert.equal(budget.tryAcquire(c), false);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
a.hiddenSince = clock.now();
budget.noteHidden(a);
assert.equal(budget.tryAcquire(c), true);
assert.equal(budget.getActiveCount(), 3, "recently hidden holders allow a grace overshoot");
a.hiddenSince = null;
b.hiddenSince = clock.now();
budget.noteHidden(b);
clock.advance(3_000);
assert.equal(b.releases, 1);
assert.equal(a.releases, 0, "a holder shown again keeps its renderer");
assert.equal(budget.getActiveCount(), 2);
});
test("a hidden pane cannot take a context once the budget is full", () => {
const { budget } = setup(2);
budget.tryAcquire(new FakeMember("a"));
const { clock, budget } = setup(2);
const a = new FakeMember("a");
budget.tryAcquire(a);
budget.tryAcquire(new FakeMember("b"));
const hidden = new FakeMember("hidden");
@@ -138,7 +156,13 @@ test("a hidden pane cannot take a context once the budget is full", () => {
assert.equal(budget.getActiveCount(), 2);
hidden.hiddenSince = null;
assert.equal(budget.tryAcquire(hidden), true, "it gets one as soon as it is shown");
assert.equal(budget.tryAcquire(hidden), false, "visible holders still fill the cap");
a.hiddenSince = clock.now();
budget.noteHidden(a);
clock.advance(3_000);
assert.equal(budget.tryAcquire(hidden), true, "a shown pane can replace an eligible hidden holder");
assert.equal(a.releases, 1);
assert.equal(budget.getActiveCount(), 2);
});
test("released and forgotten panes free their slot", () => {
@@ -10,8 +10,9 @@
*
* The budget stays below the browser limit by releasing the renderers of panes
* that have been hidden for a while, least recently seen first. Visible panes
* are never released and always get a context. A released pane builds a new
* renderer when it is shown again. Below the cap nothing changes.
* are never released, but new ones stay on DOM when visible holders fill the
* cap. Recently hidden holders may temporarily exceed the cap during tab flips.
* A released pane can rebuild when shown if the budget permits.
*/
export const MAX_ACTIVE_WEBGL_CONTEXTS = 12;
@@ -31,8 +32,8 @@ export interface WebglContextBudgetMember {
export interface WebglContextBudget {
/**
* Reserve a context for a member about to create its renderer. Visible
* members always get one; hidden members only while the budget has room.
* Reserve a context if the budget has room. Visible members may replace
* hidden holders, allowing a temporary overshoot until their grace expires.
*/
tryAcquire(member: WebglContextBudgetMember): boolean;
/** Give the slot back once the member's renderer is gone. Idempotent. */
@@ -102,7 +103,11 @@ export function createWebglContextBudget(
return {
tryAcquire(member) {
if (active.has(member)) return true;
if (member.getHiddenSince() !== null && active.size >= maxActive) return false;
if (active.size >= maxActive) {
if (member.getHiddenSince() !== null) return false;
const visibleCount = [...active].filter((holder) => holder.getHiddenSince() === null).length;
if (visibleCount >= maxActive) return false;
}
active.add(member);
releaseOverflow();
return true;
@@ -186,20 +186,93 @@ test("panes past the budget give up WebGL after staying hidden, and get it back
assert.equal(budget.getActiveCount(), 2);
});
test("simultaneous visible ensures stay within the context budget", () => {
const { budget, createPane } = setup();
const panes = Array.from({ length: 13 }, () => createPane());
for (const pane of panes) pane.controller.ensure();
assert.deepEqual(panes.map((pane) => pane.loaded), [...Array(12).fill(true), false]);
assert.equal(panes[12].addons.length, 0);
assert.equal(budget.getActiveCount(), 12);
});
test("visible context loss recovers at the cap, including with a refused visible pane", () => {
for (const withOverflow of [false, true]) {
const { clock, budget, createPane } = setup(2);
const panes = [createPane(), createPane()];
for (const pane of panes) pane.controller.ensure();
const overflow = withOverflow ? createPane() : null;
overflow?.controller.ensure();
assert.equal(overflow?.addons.length ?? 0, 0);
assert.equal(budget.getActiveCount(), 2);
panes[0].addons[0].loseContext();
assert.equal(budget.getActiveCount(), 1);
clock.advance(49);
assert.equal(panes[0].loaded, false);
clock.advance(1);
assert.equal(panes[0].loaded, true);
assert.equal(panes[0].addons.length, 2);
assert.equal(panes[1].addons.length, 1);
overflow?.controller.ensure();
assert.equal(overflow?.addons.length ?? 0, 0);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
assert.ok(panes.every((pane) => !pane.warnings.some((warning) => warning.includes("staying on DOM renderer"))));
}
});
test("visible loss stays on DOM if another visible pane takes the freed slot before recovery", () => {
const { clock, budget, createPane } = setup(2);
const panes = [createPane(), createPane(), createPane()];
for (const pane of panes) pane.controller.ensure();
assert.equal(panes[2].loaded, false);
panes[0].addons[0].loseContext();
panes[2].controller.ensure();
assert.equal(panes[2].loaded, true);
clock.advance(50);
assert.equal(panes[0].loaded, false);
assert.deepEqual(panes.map((pane) => pane.addons.length), [1, 1, 1]);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
assert.ok(panes.every((pane) => !pane.warnings.some((warning) => warning.includes("staying on DOM renderer"))));
});
test("a hidden pane mounted past the budget keeps the DOM renderer until shown", () => {
const { budget, createPane } = setup(1);
createPane().controller.ensure();
const { clock, budget, createPane } = setup(1);
const occupant = createPane();
occupant.controller.ensure();
const hidden = createPane(false);
hidden.controller.ensure();
assert.equal(hidden.addons.length, 0);
assert.equal(budget.getActiveCount(), 1);
occupant.setVisible(false);
clock.advance(3_000);
hidden.reveal();
assert.equal(hidden.loaded, true);
assert.equal(occupant.loaded, false);
assert.equal(budget.getActiveCount(), 1);
});
test("a pane on screen is never refused or released, even if a sync was missed", () => {
test("revealing a hidden pane is refused while visible holders fill the budget", () => {
const { budget, createPane } = setup(1);
const occupant = createPane();
occupant.controller.ensure();
const hidden = createPane(false);
hidden.controller.ensure();
hidden.reveal();
assert.equal(hidden.loaded, false);
assert.equal(hidden.addons.length, 0);
assert.equal(occupant.loaded, true);
assert.equal(budget.getActiveCount(), 1);
});
test("a holder on screen is never released, even if a sync was missed", () => {
const { clock, budget, createPane } = setup(1);
const visible = createPane();
visible.controller.ensure();
@@ -210,12 +283,37 @@ test("a pane on screen is never refused or released, even if a sync was missed",
createPane().controller.ensure();
clock.advance(10_000);
assert.equal(visible.loaded, true);
assert.equal(budget.getActiveCount(), 2);
assert.equal(budget.getActiveCount(), 1);
const late = createPane(false);
late.setVisibleWithoutSync(true);
late.controller.ensure();
assert.equal(late.loaded, true);
assert.equal(late.loaded, false);
assert.equal(late.addons.length, 0);
assert.equal(budget.getActiveCount(), 1);
});
test("grace overflow never releases a holder shown again without a visibility sync", () => {
const { clock, budget, createPane } = setup(1);
const holder = createPane();
holder.controller.ensure();
holder.setVisible(false);
const front = createPane();
front.controller.ensure();
assert.equal(budget.getActiveCount(), 2);
holder.setVisibleWithoutSync(true);
clock.advance(3_000);
assert.equal(holder.loaded, true);
assert.equal(front.loaded, true);
assert.equal(holder.addons[0].disposeCalls, 0);
front.addons[0].loseContext();
clock.advance(50);
assert.equal(front.loaded, false, "recovery cannot exceed a cap already filled by visible holders");
assert.equal(front.addons.length, 1);
assert.equal(budget.getActiveCount(), 1);
assert.equal(clock.pendingTimers, 0);
});
test("suspend, dispose and failed loads return the budget slot", () => {
@@ -65,7 +65,7 @@ export function createWebglRendererController<TAddon extends WebglRendererAddon>
// isVisible() is the source of truth; hiddenSince only remembers when the
// pane was last seen. Re-reading it at every decision keeps a pane that is
// on screen from being refused or released even if a sync was missed.
// on screen from being released even if a sync was missed.
const readVisibility = () => {
if (!options.isVisible) return;
if (options.isVisible()) hiddenSince = null;
@@ -94,7 +94,7 @@ export function createWebglRendererController<TAddon extends WebglRendererAddon>
const ensure = () => {
if (loaded || circuitBroken || disposed || !options.enabled) return;
// A hidden pane past the budget keeps the DOM renderer until it is shown.
// Panes past the budget keep the DOM renderer until a slot is available.
if (budget && !budget.tryAcquire(budgetMember)) return;
let nextAddon: TAddon | null = null;
try {