major remote desktop improvements
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { assetCacheControl } from "./assets-routes";
|
||||
|
||||
describe("asset cache policy", () => {
|
||||
test("revalidates stable JavaScript and stylesheet filenames after a deployment", () => {
|
||||
expect(assetCacheControl("remotedesktop.js")).toBe("no-cache");
|
||||
expect(assetCacheControl("main.min.js")).toBe("no-cache");
|
||||
expect(assetCacheControl("tailwind.css")).toBe("no-cache");
|
||||
});
|
||||
|
||||
test("keeps binary and font assets immutable", () => {
|
||||
expect(assetCacheControl("logo.png")).toContain("immutable");
|
||||
expect(assetCacheControl("inter.woff2")).toContain("immutable");
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ function acceptsGzip(req: Request): boolean {
|
||||
}
|
||||
|
||||
const STATIC_ASSET_CACHE = "public, max-age=31536000, immutable";
|
||||
const MUTABLE_ASSET_CACHE = "public, max-age=86400, stale-while-revalidate=604800";
|
||||
const NO_CACHE = "no-cache";
|
||||
const MAX_GZIP_CACHE_ENTRIES = 128;
|
||||
const MAX_GZIP_CACHE_BYTES = 16 * 1024 * 1024;
|
||||
@@ -35,16 +34,16 @@ type GzipCacheEntry = {
|
||||
const gzipAssetCache = new Map<string, GzipCacheEntry>();
|
||||
let gzipAssetCacheBytes = 0;
|
||||
|
||||
function assetCacheControl(relativePath: string): string {
|
||||
export function assetCacheControl(relativePath: string): string {
|
||||
if (relativePath === "custom.css") return NO_CACHE;
|
||||
if (relativePath === "notification-sw.js") return NO_CACHE;
|
||||
if (relativePath.endsWith(".min.js") || relativePath.endsWith(".min.css")) {
|
||||
return STATIC_ASSET_CACHE;
|
||||
}
|
||||
if (/\.(ico|png|jpg|jpeg|gif|webp|woff2?|ttf|eot|svg)$/.test(relativePath)) {
|
||||
return STATIC_ASSET_CACHE;
|
||||
}
|
||||
return MUTABLE_ASSET_CACHE;
|
||||
// Script and stylesheet filenames are stable across releases. Revalidate
|
||||
// them so a rebuilt Docker image cannot leave the previous UI cached for a
|
||||
// day (or stale-served for a week). ETags keep unchanged requests cheap.
|
||||
return NO_CACHE;
|
||||
}
|
||||
|
||||
function toExactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { generateToken } from "../../auth";
|
||||
import { createUser, deleteUser, getUserById } from "../../users";
|
||||
import { handleMiscRoutes } from "./misc-routes";
|
||||
|
||||
const password = "Aa1!CertificateDownload_2026";
|
||||
const tempRoot = mkdtempSync(path.join(tmpdir(), "overlord-cert-download-"));
|
||||
const certPath = path.join(tempRoot, "server.crt");
|
||||
const certBody = "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n";
|
||||
let userId = 0;
|
||||
let token = "";
|
||||
|
||||
const deps = {
|
||||
CORS_HEADERS: {},
|
||||
SERVER_VERSION: "test",
|
||||
PUBLIC_ROOT: ".",
|
||||
requestIP: () => ({ address: "127.0.0.1" }),
|
||||
getConsoleSessionCount: () => 0,
|
||||
getRdSessionCount: () => 0,
|
||||
getFileBrowserSessionCount: () => 0,
|
||||
getProcessSessionCount: () => 0,
|
||||
tlsCertPath: certPath,
|
||||
tlsSource: "self-signed" as const,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
writeFileSync(certPath, certBody);
|
||||
const created = await createUser(
|
||||
`cert_download_${Date.now().toString(36)}`,
|
||||
password,
|
||||
"admin",
|
||||
"test",
|
||||
);
|
||||
if (!created.success || !created.userId) throw new Error("failed to create certificate test user");
|
||||
userId = created.userId;
|
||||
token = await generateToken(getUserById(userId)!);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (userId) deleteUser(userId);
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("active TLS certificate download", () => {
|
||||
test("requires authentication", async () => {
|
||||
const url = new URL("https://localhost/api/cert/download");
|
||||
const response = await handleMiscRoutes(new Request(url), url, deps);
|
||||
expect(response?.status).toBe(401);
|
||||
});
|
||||
|
||||
test("downloads the active certificate with safe headers", async () => {
|
||||
const url = new URL("https://localhost/api/cert/download");
|
||||
const response = await handleMiscRoutes(
|
||||
new Request(url, { headers: { Cookie: `overlord_token=${token}` } }),
|
||||
url,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(response?.headers.get("content-disposition")).toBe(
|
||||
'attachment; filename="overlord-server.crt"',
|
||||
);
|
||||
expect(response?.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response?.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(await response?.text()).toBe(certBody);
|
||||
});
|
||||
});
|
||||
@@ -1380,7 +1380,9 @@ export async function handleMiscRoutes(
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-pem-file",
|
||||
"Content-Disposition": 'attachment; filename="overlord-ca.crt"',
|
||||
"Content-Disposition": 'attachment; filename="overlord-server.crt"',
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...deps.CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -146,12 +146,12 @@ describe("remote desktop viewer control", () => {
|
||||
|
||||
handleRemoteDesktopViewerMessage(viewerSocket, JSON.stringify({
|
||||
type: "desktop_request_keyframe",
|
||||
reason: "viewer_frame_gap",
|
||||
reason: "h264_decoder_keyframe_required",
|
||||
}));
|
||||
const automaticCommand = agentCommands(agentWs)
|
||||
.filter((msg) => msg.commandType === "desktop_request_keyframe")
|
||||
.at(-1);
|
||||
expect(automaticCommand?.payload).toEqual({ reason: "viewer_frame_gap" });
|
||||
expect(automaticCommand?.payload).toEqual({ reason: "h264_decoder_keyframe_required" });
|
||||
});
|
||||
|
||||
test("requests HEVC recovery after a screenshot only while streaming", () => {
|
||||
@@ -381,9 +381,39 @@ describe("remote desktop viewer control", () => {
|
||||
{ format: "h264", fps: 60, width: 2560, height: 1440 },
|
||||
);
|
||||
expect(pressuredAck).toBe(false);
|
||||
for (let frameNumber = 0; frameNumber < 3; frameNumber++) {
|
||||
const additionalAck = (globalThis as any).__rdBroadcast(
|
||||
clientId,
|
||||
new Uint8Array([7 + frameNumber]),
|
||||
{ format: "h264", fps: 60, width: 2560, height: 1440 },
|
||||
);
|
||||
expect(additionalAck).toBe(false);
|
||||
}
|
||||
|
||||
handleRemoteDesktopViewerMessage(viewer as any, JSON.stringify({ type: "desktop_decode_pressure", active: false }));
|
||||
expect(agentCommands(agentWs).filter((msg) => msg.type === "frame_ack")).toHaveLength(1);
|
||||
expect(agentCommands(agentWs).filter((msg) => msg.type === "frame_ack")).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("coalesces keyframe requests while a viewer remains backpressured", () => {
|
||||
const clientId = `rd-backpressure-${Date.now().toString(36)}`;
|
||||
const { agentWs } = createClient(clientId);
|
||||
const viewer = createMockWs({ clientId });
|
||||
viewer.getBufferedAmount = () => 2 * 1024 * 1024;
|
||||
handleRemoteDesktopViewerOpen(viewer as any);
|
||||
|
||||
for (let frame = 0; frame < 3; frame++) {
|
||||
const shouldAck = (globalThis as any).__rdBroadcast(
|
||||
clientId,
|
||||
new Uint8Array([frame]),
|
||||
{ format: "h264", fps: 60, width: 1920, height: 1080 },
|
||||
);
|
||||
expect(shouldAck).toBe(true);
|
||||
}
|
||||
|
||||
const requests = agentCommands(agentWs)
|
||||
.filter((msg) => msg.commandType === "desktop_request_keyframe");
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.payload?.reason).toBe("viewer_backpressure");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ type FrameBroadcastResult = {
|
||||
sent: boolean;
|
||||
dropped: boolean;
|
||||
viewers: number;
|
||||
sentViewers: Set<ServerWebSocket<SocketData>>;
|
||||
};
|
||||
|
||||
function broadcastFrameToViewers(
|
||||
@@ -148,6 +149,7 @@ function broadcastFrameToViewers(
|
||||
let sent = false;
|
||||
let dropped = false;
|
||||
let viewers = 0;
|
||||
const sentViewers = new Set<ServerWebSocket<SocketData>>();
|
||||
const t0 = performance.now();
|
||||
const byteLen = buf.length;
|
||||
for (const session of sessions) {
|
||||
@@ -161,6 +163,7 @@ function broadcastFrameToViewers(
|
||||
session.viewer.send(buf);
|
||||
metrics.recordBytesSent(byteLen);
|
||||
sent = true;
|
||||
sentViewers.add(session.viewer);
|
||||
} catch (err) {
|
||||
logger.error("[rd] viewer frame send failed", err);
|
||||
}
|
||||
@@ -172,14 +175,15 @@ function broadcastFrameToViewers(
|
||||
rdSendStats.sendMs += elapsed;
|
||||
}
|
||||
logRdSend(header);
|
||||
return { sent, dropped, viewers };
|
||||
return { sent, dropped, viewers, sentViewers };
|
||||
}
|
||||
|
||||
const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 };
|
||||
const rdDebugFrameLogAt = new Map<string, number>();
|
||||
const rdCanvasFrameAckPending = new Map<string, string>();
|
||||
const rdCanvasFrameAckPending = new Map<string, { sessionId: string; count: number }>();
|
||||
const rdBackpressureKeyframeAt = new Map<string, number>();
|
||||
const RD_BACKPRESSURE_KEYFRAME_INTERVAL_MS = 1_000;
|
||||
const AUTOMATIC_DESKTOP_KEYFRAME_REASONS: Record<string, true> = {
|
||||
viewer_frame_gap: true,
|
||||
h264_decoder_keyframe_required: true,
|
||||
hevc_decoder_keyframe_required: true,
|
||||
};
|
||||
@@ -487,6 +491,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
.filter(s => s.id !== ws.data.sessionId);
|
||||
logger.debug(`[rd-debug] desktop_stop requested client=${clientId} session=${ws.data.sessionId || ""} otherViewers=${otherViewers.length} state=${JSON.stringify(state)}`);
|
||||
if (otherViewers.length === 0) {
|
||||
rdBackpressureKeyframeAt.delete(clientId);
|
||||
stopRemoteDesktopRecording(clientId, "desktop stopped");
|
||||
sendDesktopCommand(target, "desktop_stop", {});
|
||||
sendDesktopCommand(target, "webrtc_stop", { kind: "desktop" });
|
||||
@@ -863,23 +868,32 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header
|
||||
const result = broadcastFrameToViewers(sessions, buf, header);
|
||||
if (result.sent) {
|
||||
const controller = sessions.find((session) =>
|
||||
result.sentViewers.has(session.viewer) &&
|
||||
(session.viewer.data as any).rdCanvasFlowControl === true &&
|
||||
(session.viewer.data as any).rdCanvasBackpressure === true
|
||||
);
|
||||
if (controller?.viewer.data.sessionId) {
|
||||
rdCanvasFrameAckPending.set(clientId, controller.viewer.data.sessionId);
|
||||
const sessionId = controller.viewer.data.sessionId;
|
||||
const pending = rdCanvasFrameAckPending.get(clientId);
|
||||
rdCanvasFrameAckPending.set(clientId, {
|
||||
sessionId,
|
||||
count: pending?.sessionId === sessionId ? pending.count + 1 : 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (result.dropped) {
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (target) {
|
||||
const now = Date.now();
|
||||
const lastRequestAt = rdBackpressureKeyframeAt.get(clientId) || 0;
|
||||
if (target && now - lastRequestAt >= RD_BACKPRESSURE_KEYFRAME_INTERVAL_MS) {
|
||||
rdBackpressureKeyframeAt.set(clientId, now);
|
||||
sendDesktopCommand(target, "desktop_request_keyframe", {
|
||||
reason: "viewer_backpressure",
|
||||
format: String(header?.format || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
return (result.sent && !rdCanvasFrameAckPending.has(clientId)) || result.viewers === 0;
|
||||
return ((result.sent || result.dropped) && !rdCanvasFrameAckPending.has(clientId)) || result.viewers === 0;
|
||||
}
|
||||
|
||||
(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||
@@ -1588,13 +1602,16 @@ export function handleDesktopEncoderCapabilities(clientId: string, payload: any)
|
||||
}
|
||||
|
||||
function releaseCanvasFrameAck(clientId: string, sessionId?: string): boolean {
|
||||
const controllerSessionId = rdCanvasFrameAckPending.get(clientId);
|
||||
if (!controllerSessionId || (sessionId && controllerSessionId !== sessionId)) return false;
|
||||
const pending = rdCanvasFrameAckPending.get(clientId);
|
||||
if (!pending || (sessionId && pending.sessionId !== sessionId)) return false;
|
||||
rdCanvasFrameAckPending.delete(clientId);
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) return false;
|
||||
try {
|
||||
target.ws.send(encodeMessage({ type: "frame_ack" }));
|
||||
const ack = encodeMessage({ type: "frame_ack" });
|
||||
for (let i = 0; i < pending.count; i++) {
|
||||
target.ws.send(ack);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.debug(`[rd] canvas frame ack failed client=${clientId}: ${(err as Error).message}`);
|
||||
|
||||
Reference in New Issue
Block a user