mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 17:00:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de8544c89c | ||
|
|
05ab2818db | ||
|
|
7f4d41cea1 | ||
|
|
6461200f59 | ||
|
|
d62d6bdab8 |
No files matched your search
@@ -0,0 +1,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
|
||||
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
|
||||
const attempts: string[] = [];
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["images", "plain"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
attempts.push(sessionId);
|
||||
return sessionId === "plain";
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ["images", "plain"]);
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
|
||||
const blocked: string[] = [];
|
||||
const blockedOutcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["images", "also-images"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => blockedOutcomes.push("soft-hide"),
|
||||
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
blocked.push(sessionId);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(blocked, ["images", "also-images"]);
|
||||
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
|
||||
const attempts: string[] = [];
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
attempts.push(sessionId);
|
||||
await Promise.resolve();
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ["victim"]);
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
|
||||
const recovered: string[] = [];
|
||||
const recoveredOutcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim", "next"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => recoveredOutcomes.push("soft-hide"),
|
||||
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
recovered.push(sessionId);
|
||||
await Promise.resolve();
|
||||
return sessionId === "next";
|
||||
},
|
||||
});
|
||||
assert.deepEqual(recovered, ["victim", "next"]);
|
||||
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
|
||||
});
|
||||
|
||||
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => false,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => true,
|
||||
});
|
||||
assert.deepEqual(outcomes, []);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
|
||||
const sessions = ["victim", "other", "leaving"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
});
|
||||
|
||||
const outcomes: string[] = [];
|
||||
const pending = retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => {
|
||||
terminalHiddenRendererStore.markSoftHidden("leaving");
|
||||
outcomes.push("soft-hide");
|
||||
},
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 2,
|
||||
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
|
||||
sessionId: "second",
|
||||
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
|
||||
}), { action: "await-eviction", candidates: ["other"] });
|
||||
|
||||
terminalHiddenRendererStore.settleEviction("victim", true);
|
||||
await pending;
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
terminalHiddenRendererStore.settleEviction("victim", false);
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
await pending.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
|
||||
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
queueMicrotask(() => {
|
||||
terminalHiddenRendererStore.settleEviction("victim", true);
|
||||
});
|
||||
});
|
||||
|
||||
const retained: string[] = [];
|
||||
const hibernated: string[] = [];
|
||||
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
|
||||
sessionId,
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => {
|
||||
terminalHiddenRendererStore.markSoftHidden(sessionId);
|
||||
retained.push(sessionId);
|
||||
},
|
||||
fullHibernate: () => hibernated.push(sessionId),
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
|
||||
assert.equal(retained.length, 1);
|
||||
assert.equal(hibernated.length, 1);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
/**
|
||||
* Walk keep-count victims until one hibernate completes, then retain the
|
||||
* leaving tab only if its reserved slot still fits.
|
||||
*
|
||||
* The reservation is taken before the first await. Clearing a victim marker
|
||||
* during wake would otherwise look like spare capacity to a second hidden-tab
|
||||
* timer, and both tabs would soft-hide once the same eviction resolved.
|
||||
*/
|
||||
export async function retainLeavingTabAfterEviction(input: {
|
||||
sessionId: string;
|
||||
candidates: readonly string[];
|
||||
keepCount: number;
|
||||
stillWantsRetention: () => boolean;
|
||||
softHide: () => void;
|
||||
fullHibernate: () => void;
|
||||
evict?: (sessionId: string) => Promise<boolean> | boolean;
|
||||
}): Promise<void> {
|
||||
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
|
||||
try {
|
||||
const evict = input.evict ?? ((sessionId: string) => (
|
||||
terminalHiddenRendererStore.evictSoftHidden(sessionId)
|
||||
));
|
||||
let completed = false;
|
||||
for (const candidate of input.candidates) {
|
||||
if (!candidate || candidate === input.sessionId) continue;
|
||||
if (await evict(candidate)) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!input.stillWantsRetention()) return;
|
||||
if (
|
||||
completed
|
||||
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, input.keepCount)
|
||||
) {
|
||||
input.softHide();
|
||||
return;
|
||||
}
|
||||
input.fullHibernate();
|
||||
} finally {
|
||||
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,106 @@ test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
|
||||
assert.deepEqual(terminalHiddenRendererStore.listSoftHiddenOldestFirst(), ["a", "b"]);
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("b");
|
||||
});
|
||||
|
||||
test("evictSoftHidden reports refusal and drops an unconsumed request", async () => {
|
||||
const sessions = ["images", "plain", "other"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("images");
|
||||
terminalHiddenRendererStore.markSoftHidden("plain");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) return;
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("plain")) {
|
||||
terminalHiddenRendererStore.clearSoftHidden("plain");
|
||||
terminalHiddenRendererStore.settleEviction("plain", true);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("plain"), true);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("plain"), false);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
|
||||
terminalHiddenRendererStore.markSoftHidden("images");
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
|
||||
const delivered: string[] = [];
|
||||
const later = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) delivered.push("images");
|
||||
});
|
||||
try {
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
assert.deepEqual(delivered, []);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
|
||||
} finally {
|
||||
later();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("a reserved leaving slot counts toward occupancy until it is released", () => {
|
||||
const sessions = ["a", "b", "c", "d"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
|
||||
const alreadyHidden = terminalHiddenRendererStore.reserveLeavingSlot("a");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("a", alreadyHidden);
|
||||
|
||||
const tokenC = terminalHiddenRendererStore.reserveLeavingSlot("c");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
|
||||
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), true);
|
||||
|
||||
const tokenD = terminalHiddenRendererStore.reserveLeavingSlot("d");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("d", 2), false);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenD);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenC);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("d", tokenD);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 1);
|
||||
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("evictSoftHidden waits for hibernate after the marker is cleared", async () => {
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
let markerClearedBeforeSettle = false;
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
markerClearedBeforeSettle = !terminalHiddenRendererStore.isSoftHidden("victim");
|
||||
queueMicrotask(() => {
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.settleEviction("victim", false);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const completed = await terminalHiddenRendererStore.evictSoftHidden("victim");
|
||||
assert.equal(markerClearedBeforeSettle, true);
|
||||
assert.equal(completed, false);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
}
|
||||
});
|
||||
@@ -3,14 +3,85 @@ type Listener = () => void;
|
||||
const softHiddenSessions = new Map<string, number>();
|
||||
const listeners = new Set<Listener>();
|
||||
let evictionRequestSessionId: string | null = null;
|
||||
/** Leaving tabs that have claimed a keep slot while a victim hibernate is in flight. */
|
||||
const reservedLeavingSlots = new Map<string, number>();
|
||||
let reservationToken = 0;
|
||||
|
||||
function reservedLeavingCount(): number {
|
||||
let extra = 0;
|
||||
for (const sessionId of reservedLeavingSlots.keys()) {
|
||||
if (!softHiddenSessions.has(sessionId)) extra += 1;
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
type EvictionWaiter = {
|
||||
promise: Promise<boolean>;
|
||||
resolve: (completed: boolean) => void;
|
||||
settled: boolean;
|
||||
};
|
||||
|
||||
const evictionWaiters = new Map<string, EvictionWaiter>();
|
||||
|
||||
function finishEviction(sessionId: string, completed: boolean): void {
|
||||
const waiter = evictionWaiters.get(sessionId);
|
||||
if (!waiter || waiter.settled) return;
|
||||
waiter.settled = true;
|
||||
evictionWaiters.delete(sessionId);
|
||||
if (evictionRequestSessionId === sessionId) evictionRequestSessionId = null;
|
||||
waiter.resolve(completed);
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function listSoftHiddenOldestFirst(): string[] {
|
||||
return [...softHiddenSessions.entries()]
|
||||
.sort((left, right) => left[1] - right[1])
|
||||
.map(([sessionId]) => sessionId);
|
||||
}
|
||||
|
||||
export const terminalHiddenRendererStore = {
|
||||
getSoftHiddenCount: () => softHiddenSessions.size,
|
||||
|
||||
/**
|
||||
* Soft-hidden markers plus leaving tabs that reserved a slot and are not
|
||||
* yet in the marker set. Keep-count decisions use this so a second timer
|
||||
* cannot take the slot a pending eviction already claimed.
|
||||
*/
|
||||
getSoftHiddenOccupancy: () => softHiddenSessions.size + reservedLeavingCount(),
|
||||
|
||||
/**
|
||||
* Claim one keep slot for a leaving tab before awaiting eviction.
|
||||
* A session already in the marker set is not counted twice. A later claim
|
||||
* for the same id replaces the token; only that token can release it.
|
||||
*/
|
||||
reserveLeavingSlot: (sessionId: string): number => {
|
||||
const token = ++reservationToken;
|
||||
if (sessionId && !softHiddenSessions.has(sessionId)) {
|
||||
reservedLeavingSlots.set(sessionId, token);
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
releaseLeavingSlot: (sessionId: string, token: number) => {
|
||||
if (reservedLeavingSlots.get(sessionId) === token) {
|
||||
reservedLeavingSlots.delete(sessionId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* True when retaining this leaving tab would stay within keepCount.
|
||||
* Its own reservation is not counted against the slots held by others.
|
||||
*/
|
||||
leavingSlotStillFits: (sessionId: string, keepCount: number): boolean => {
|
||||
if (!(keepCount > 0)) return false;
|
||||
const reservedHere = reservedLeavingSlots.has(sessionId) && !softHiddenSessions.has(sessionId);
|
||||
const others = softHiddenSessions.size + reservedLeavingCount() - (reservedHere ? 1 : 0);
|
||||
return others < keepCount;
|
||||
},
|
||||
|
||||
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
|
||||
|
||||
markSoftHidden: (sessionId: string) => {
|
||||
@@ -23,18 +94,12 @@ export const terminalHiddenRendererStore = {
|
||||
emit();
|
||||
},
|
||||
|
||||
listSoftHiddenOldestFirst: (): string[] => listSoftHiddenOldestFirst(),
|
||||
|
||||
/** Returns the oldest soft-hidden session id when over the keep limit. */
|
||||
pickEvictionCandidate: (keepCount: number): string | null => {
|
||||
if (softHiddenSessions.size < keepCount) return null;
|
||||
let oldestId: string | null = null;
|
||||
let oldestTs = Number.POSITIVE_INFINITY;
|
||||
for (const [sessionId, ts] of softHiddenSessions) {
|
||||
if (ts < oldestTs) {
|
||||
oldestTs = ts;
|
||||
oldestId = sessionId;
|
||||
}
|
||||
}
|
||||
return oldestId;
|
||||
return listSoftHiddenOldestFirst()[0] ?? null;
|
||||
},
|
||||
|
||||
requestEviction: (sessionId: string) => {
|
||||
@@ -42,6 +107,44 @@ export const terminalHiddenRendererStore = {
|
||||
emit();
|
||||
},
|
||||
|
||||
/** True while this session's hibernate has been requested and not settled. */
|
||||
hasPendingEviction: (sessionId: string): boolean => {
|
||||
const waiter = evictionWaiters.get(sessionId);
|
||||
return waiter != null && !waiter.settled;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask one soft-hidden session to upgrade. Resolves true only when
|
||||
* settleEviction reports that hibernation finished. Clearing the marker
|
||||
* during wake is not success: drain, snapshot, or alternate-screen failure
|
||||
* can still restore that renderer. A synchronous refusal (marker still
|
||||
* present, including inline images) resolves false and drops an unconsumed
|
||||
* request so a later emit cannot upgrade it.
|
||||
*/
|
||||
evictSoftHidden: (sessionId: string): Promise<boolean> => {
|
||||
const existing = evictionWaiters.get(sessionId);
|
||||
if (existing) return existing.promise;
|
||||
if (!softHiddenSessions.has(sessionId)) return Promise.resolve(false);
|
||||
|
||||
let resolvePromise!: (completed: boolean) => void;
|
||||
const promise = new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
const waiter: EvictionWaiter = { promise, resolve: resolvePromise, settled: false };
|
||||
evictionWaiters.set(sessionId, waiter);
|
||||
evictionRequestSessionId = sessionId;
|
||||
emit();
|
||||
if (!waiter.settled && softHiddenSessions.has(sessionId)) {
|
||||
finishEviction(sessionId, false);
|
||||
}
|
||||
return promise;
|
||||
},
|
||||
|
||||
/** Record whether the in-flight eviction's hibernate actually completed. */
|
||||
settleEviction: (sessionId: string, completed: boolean) => {
|
||||
finishEviction(sessionId, completed);
|
||||
},
|
||||
|
||||
consumeEvictionRequest: (sessionId: string): boolean => {
|
||||
if (evictionRequestSessionId !== sessionId) return false;
|
||||
evictionRequestSessionId = null;
|
||||
|
||||
+67
-21
@@ -243,6 +243,7 @@ import {
|
||||
import {
|
||||
canHibernateTerminalRuntimeSession,
|
||||
canHibernateTerminalRuntimeStatus,
|
||||
decideHiddenTabKeepCountAction,
|
||||
isTerminalFileTransferActive,
|
||||
resolveHibernateKeepRendererCount,
|
||||
resolveHibernatePreferWasmSerialize,
|
||||
@@ -254,6 +255,7 @@ import {
|
||||
} from "../domain/terminalHibernate";
|
||||
import { getLastChar, removeLastChar } from "../domain/serialCharMetrics";
|
||||
import { stringCellWidth } from "./terminal/autocomplete/terminalStringCellWidth";
|
||||
import { retainLeavingTabAfterEviction } from "../application/state/hiddenTabEviction";
|
||||
import { terminalHiddenRendererStore } from "../application/state/terminalHiddenRendererStore";
|
||||
import {
|
||||
wakeTerminalFromHibernate,
|
||||
@@ -522,6 +524,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hibernateAlternateScreenRef = useRef(false);
|
||||
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const hibernateEvictionAttemptRef = useRef(0);
|
||||
const wakeSoftHiddenRuntimeRef = useRef<(() => void) | null>(null);
|
||||
const resumeRendererAfterCancelledHibernateUpgradeRef = useRef<(() => void) | null>(null);
|
||||
const wakeInProgressRef = useRef(false);
|
||||
@@ -2297,20 +2300,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
]);
|
||||
fullHibernateRuntimeRef.current = fullHibernateRuntime;
|
||||
|
||||
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
|
||||
if (!wakeSoftHiddenRuntimeRef.current) return;
|
||||
wakeSoftHiddenRuntimeRef.current?.();
|
||||
void fullHibernateRuntime().then(
|
||||
(completed) => {
|
||||
if (!completed) resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
},
|
||||
(error) => {
|
||||
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
|
||||
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
},
|
||||
);
|
||||
}, [fullHibernateRuntime, sessionId]);
|
||||
|
||||
const hideRuntimeOnly = useCallback(() => {
|
||||
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
|
||||
xtermRuntimeRef.current?.suspendWebglRenderer();
|
||||
@@ -2319,6 +2308,39 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
logger.info("[Terminal] Soft-hidden runtime", { sessionId });
|
||||
}, [sessionId]);
|
||||
|
||||
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
|
||||
const pendingEviction = terminalHiddenRendererStore.hasPendingEviction(sessionId);
|
||||
if (!wakeSoftHiddenRuntimeRef.current) {
|
||||
terminalHiddenRendererStore.settleEviction(sessionId, false);
|
||||
return;
|
||||
}
|
||||
wakeSoftHiddenRuntimeRef.current?.();
|
||||
void fullHibernateRuntime().then(
|
||||
(completed) => {
|
||||
if (!completed) {
|
||||
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
// Wake already dropped the marker. Restore the soft-hidden slot only
|
||||
// while this tab is still hidden and hibernate is still enabled.
|
||||
// Selecting the victim during the drain makes fullHibernateRuntime
|
||||
// return false because the tab is visible; soft-hiding it again
|
||||
// would hide the tab the user just opened.
|
||||
if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current) {
|
||||
hideRuntimeOnly();
|
||||
}
|
||||
}
|
||||
terminalHiddenRendererStore.settleEviction(sessionId, completed);
|
||||
},
|
||||
(error) => {
|
||||
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
|
||||
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current) {
|
||||
hideRuntimeOnly();
|
||||
}
|
||||
terminalHiddenRendererStore.settleEviction(sessionId, false);
|
||||
},
|
||||
);
|
||||
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId]);
|
||||
|
||||
const hibernateRuntime = useCallback(() => {
|
||||
if (hibernatedRef.current || !termRef.current) return;
|
||||
|
||||
@@ -2343,16 +2365,40 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const keepCount = statusRef.current === "connected"
|
||||
? resolveHibernateKeepRendererCount(terminalSettings)
|
||||
: 0;
|
||||
if (keepCount > 0 && terminalHiddenRendererStore.getSoftHiddenCount() < keepCount) {
|
||||
const keepDecision = decideHiddenTabKeepCountAction({
|
||||
keepCount,
|
||||
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
|
||||
sessionId,
|
||||
candidatesOldestFirst: keepCount > 0
|
||||
? terminalHiddenRendererStore.listSoftHiddenOldestFirst()
|
||||
: [],
|
||||
});
|
||||
if (keepDecision.action === "soft-hide") {
|
||||
hideRuntimeOnly();
|
||||
return;
|
||||
}
|
||||
|
||||
if (keepCount > 0) {
|
||||
const victim = terminalHiddenRendererStore.pickEvictionCandidate(keepCount);
|
||||
if (victim && victim !== sessionId) {
|
||||
terminalHiddenRendererStore.requestEviction(victim);
|
||||
}
|
||||
if (keepDecision.action === "await-eviction") {
|
||||
const attempt = ++hibernateEvictionAttemptRef.current;
|
||||
// Reserves this tab's slot before the first await, then soft-hides only
|
||||
// if a victim hibernate completes and the slot still fits. A sync marker
|
||||
// clear is not enough: alt-screen and drain/snapshot failure restore the
|
||||
// victim, and this tab stays out of the soft-hidden set.
|
||||
void retainLeavingTabAfterEviction({
|
||||
sessionId,
|
||||
candidates: keepDecision.candidates,
|
||||
keepCount,
|
||||
stillWantsRetention: () => (
|
||||
hibernateEvictionAttemptRef.current === attempt
|
||||
&& !isVisibleRef.current
|
||||
&& !hibernatedRef.current
|
||||
&& !softHiddenRef.current
|
||||
),
|
||||
softHide: () => hideRuntimeOnly(),
|
||||
fullHibernate: () => {
|
||||
void fullHibernateRuntime();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void fullHibernateRuntime();
|
||||
|
||||
@@ -84,7 +84,7 @@ test("a session holding inline images degrades to soft-hide instead of full hibe
|
||||
const gateIndex = hibernateBody.indexOf("if (runtimeHasInlineImages()) {");
|
||||
const softHideIndex = hibernateBody.indexOf("hideRuntimeOnly();", gateIndex);
|
||||
const keepCountIndex = hibernateBody.indexOf("resolveHibernateKeepRendererCount(terminalSettings)");
|
||||
const evictionIndex = hibernateBody.indexOf("terminalHiddenRendererStore.pickEvictionCandidate(");
|
||||
const evictionIndex = hibernateBody.indexOf("retainLeavingTabAfterEviction(");
|
||||
const fullHibernateIndex = hibernateBody.indexOf("void fullHibernateRuntime()");
|
||||
|
||||
assert.notEqual(gateIndex, -1, "hibernate must check for inline images");
|
||||
|
||||
@@ -239,6 +239,16 @@ test("a cancelled soft-hidden upgrade resumes its renderer", () => {
|
||||
assert.match(helperBody, /ensureWebglRenderer\(\)/);
|
||||
assert.match(helperBody, /clearTextureAtlas\(\)/);
|
||||
assert.match(helperBody, /safeFitRef\.current\(\{ force: true \}\)/);
|
||||
const restoreGuard = "if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current)";
|
||||
assert.equal(
|
||||
upgradeBody.split(restoreGuard).length - 1,
|
||||
2,
|
||||
"both the failed hibernate and the rejection path must skip soft-hide when the tab is visible or hibernate is off",
|
||||
);
|
||||
assert.ok(
|
||||
upgradeBody.indexOf(restoreGuard) > upgradeIndex,
|
||||
"the visibility guard belongs on the settled upgrade, after the renderer was resumed",
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshot and handoff paths reject output that misses the settle deadline", () => {
|
||||
@@ -282,3 +292,29 @@ test("snapshot and handoff paths reject output that misses the settle deadline",
|
||||
/const flushed = await flushPendingTerminalWritesBeforeHibernate\(snapshotTerm\);\s*if \(!flushed\) \{\s*throw new Error\("Terminal output did not settle before closing the attached display"\);\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a full keep count soft-hides the leaving session instead of snapshotting it", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const body = readFunctionBody(source, "const hibernateRuntime = useCallback(() =>");
|
||||
const imageGate = body.indexOf("if (runtimeHasInlineImages()) {");
|
||||
const alternateGate = body.indexOf("shouldSkipHibernateForActiveAlternateScreen(termRef.current)");
|
||||
const decision = body.indexOf("decideHiddenTabKeepCountAction(");
|
||||
const softHide = body.indexOf('if (keepDecision.action === "soft-hide")');
|
||||
const hide = body.indexOf("hideRuntimeOnly();", softHide);
|
||||
const full = body.indexOf("void fullHibernateRuntime()");
|
||||
|
||||
assert.ok(alternateGate !== -1 && alternateGate < imageGate);
|
||||
assert.ok(imageGate !== -1 && imageGate < decision);
|
||||
assert.ok(decision < softHide && softHide < hide && hide < full);
|
||||
const awaited = body.indexOf("retainLeavingTabAfterEviction({");
|
||||
assert.ok(awaited > decision, "a full keep set retains the leaving tab from the application eviction walk");
|
||||
assert.match(body, /getSoftHiddenOccupancy\(\)/);
|
||||
assert.match(body, /stillWantsRetention:/);
|
||||
assert.match(body, /softHide: \(\) => hideRuntimeOnly\(\)/);
|
||||
assert.doesNotMatch(body, /resolveHiddenTabEviction/);
|
||||
assert.doesNotMatch(body, /requestEviction\(keepDecision/);
|
||||
assert.match(
|
||||
body,
|
||||
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
canHibernateTerminalRuntimeStatus,
|
||||
capHibernateBuffer,
|
||||
capHibernateBufferByLines,
|
||||
decideHiddenTabKeepCountAction,
|
||||
isTerminalFileTransferActive,
|
||||
normalizeHibernateHiddenTabsDelaySec,
|
||||
resolveTerminalHibernateDelayMs,
|
||||
@@ -92,6 +93,54 @@ test("normalizeHibernateHiddenTabsDelaySec clamps to the allowed range", () => {
|
||||
assert.equal(normalizeHibernateHiddenTabsDelaySec(9999), TERMINAL_HIBERNATE_DELAY_SEC_MAX);
|
||||
});
|
||||
|
||||
test("a full keep-count lists eviction candidates and does not evict them itself", () => {
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 2,
|
||||
softHiddenCount: 1,
|
||||
sessionId: "leaving",
|
||||
candidatesOldestFirst: ["older"],
|
||||
}), { action: "soft-hide", candidates: [] });
|
||||
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 2,
|
||||
softHiddenCount: 2,
|
||||
sessionId: "leaving",
|
||||
candidatesOldestFirst: ["oldest", "newer"],
|
||||
}), { action: "await-eviction", candidates: ["oldest", "newer"] });
|
||||
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 2,
|
||||
softHiddenCount: 3,
|
||||
sessionId: "leaving",
|
||||
candidatesOldestFirst: ["oldest"],
|
||||
}), { action: "await-eviction", candidates: ["oldest"] });
|
||||
});
|
||||
|
||||
test("keep-count zero still full-hibernates and does not evict", () => {
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 0,
|
||||
softHiddenCount: 4,
|
||||
sessionId: "leaving",
|
||||
candidatesOldestFirst: ["oldest"],
|
||||
}), { action: "full-hibernate", candidates: [] });
|
||||
});
|
||||
|
||||
test("a full keep-count does not evict the session that is deciding", () => {
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 2,
|
||||
softHiddenCount: 2,
|
||||
sessionId: "leaving",
|
||||
candidatesOldestFirst: ["leaving"],
|
||||
}), { action: "full-hibernate", candidates: [] });
|
||||
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 2,
|
||||
softHiddenCount: 2,
|
||||
sessionId: "leaving",
|
||||
candidatesOldestFirst: ["leaving", "other"],
|
||||
}), { action: "await-eviction", candidates: ["other"] });
|
||||
});
|
||||
|
||||
test("resolveTerminalHibernateDelayMs converts seconds to milliseconds", () => {
|
||||
assert.equal(resolveTerminalHibernateDelayMs(), TERMINAL_HIBERNATE_DELAY_SEC_DEFAULT * 1000);
|
||||
assert.equal(resolveTerminalHibernateDelayMs({ hibernateHiddenTabsDelaySec: 10 }), 10_000);
|
||||
|
||||
@@ -158,6 +158,50 @@ export function shouldSkipHibernateForAltScreen(
|
||||
return alternateScreen && resolveHibernateSkipAltScreen(settings);
|
||||
}
|
||||
|
||||
export type HiddenTabKeepCountDecision = {
|
||||
/**
|
||||
* Soft-hide immediately when the keep set has room. Await eviction when it
|
||||
* is full. Full-hibernate when keepCount is 0 or no other candidate exists.
|
||||
* Callers retain the leaving tab only after a victim's hibernate completes.
|
||||
*/
|
||||
action: "soft-hide" | "full-hibernate" | "await-eviction";
|
||||
/** Oldest-first victims to try when action is await-eviction. */
|
||||
candidates: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep-count policy for a hidden session that is not already soft-hidden.
|
||||
* Callers still handle alternate screen, inline images, and a soft-hidden
|
||||
* session that later disconnects before asking. keepCount 0 (disconnected, or
|
||||
* the setting) full-hibernates. When the soft-hide set is full this only
|
||||
* lists candidates. It does not evict, reserve a slot, or touch renderer
|
||||
* lifecycle. Waking a victim clears its marker before hibernate finishes, so
|
||||
* the caller awaits that outside this helper. Wake replay order stays in
|
||||
* resolveHibernateWakeHistory (#2762). Atlas clear on a soft wake stays in
|
||||
* wakeSoftHiddenRuntime (#1063).
|
||||
*/
|
||||
export function decideHiddenTabKeepCountAction(input: {
|
||||
keepCount: number;
|
||||
softHiddenCount: number;
|
||||
sessionId: string;
|
||||
/** Oldest soft-hidden session first. Snapshot this before eviction mutates it. */
|
||||
candidatesOldestFirst: readonly string[];
|
||||
}): HiddenTabKeepCountDecision {
|
||||
if (!(input.keepCount > 0)) {
|
||||
return { action: "full-hibernate", candidates: [] };
|
||||
}
|
||||
if (input.softHiddenCount < input.keepCount) {
|
||||
return { action: "soft-hide", candidates: [] };
|
||||
}
|
||||
const candidates = input.candidatesOldestFirst.filter((candidate) => (
|
||||
Boolean(candidate) && candidate !== input.sessionId
|
||||
));
|
||||
if (candidates.length === 0) {
|
||||
return { action: "full-hibernate", candidates: [] };
|
||||
}
|
||||
return { action: "await-eviction", candidates };
|
||||
}
|
||||
|
||||
/** Snapshot + buffered output to replay when recreating xterm after hibernate. */
|
||||
export type TerminalHibernateWakePayload = {
|
||||
/** Full serialized terminal state -- preferred wake history (coherent ANSI). */
|
||||
|
||||
Reference in new issue
Block a user