* Eliminate spiky terminal lag under load
Terminal output stuttered during heavy output and when the daemon was
busy. Two compounding causes, both confirmed by measurement:
- Every 256KB of output, the daemon dropped pending frames and re-sent a
full cell-grid snapshot (cloned across IPC, stringified to JSON) — a
10MB build did this ~40 times. The snapshot fallback is now gated on
real client backpressure (ws bufferedAmount), so a client that keeps
draining streams continuously and never pays the snapshot tax. This
also removes the periodic GC hitch the churn caused.
- Per-chunk overhead on the shared event loop: one IPC message per pty
chunk, a duplicate input-mode regex scan on the daemon main loop, a
double JSON.stringify per outbound message, and 16KB string realloc
per chunk. Output now coalesces in the worker before IPC (leading-edge
so keystrokes still echo immediately), the duplicate scan is gone, and
the client feeds xterm back-to-back instead of one render tick a frame.
Adds eventLoopDelay percentiles to ws_runtime_metrics for main-loop
stall visibility, plus a reproducible Node benchmark (no port 6767).
Measured: echo p50 7.7ms to 2.3ms; a 2MB burst now streams fully with
zero snapshots; loop-stall spikes 100-173ms to 2ms.
* Guard trace-level check against partial logger stubs
The isLevelEnabled gate added for the emit() perf fix crashed every
session test that injects a hand-rolled logger stub (10 of them omit
isLevelEnabled). Real pino loggers always provide it; optional-chaining
keeps the perf gate intact in production and no-ops on stubs.
* Make recent-output exit-summary test deterministic
The added test self-exited the child immediately after writing 3000
lines, then asserted the newest line was in the exit summary — but the
summary reads the headless xterm buffer, which parses writes
asynchronously, so a loaded CI runner saw a stale buffer (line-2707 not
line-2999). Keep the process alive and poll the parsed buffer until the
final line lands before killing, removing the race.
* Skip ConPTY-fragile worker terminal tests on Windows
The coalescing and input-mode-preamble tests assert byte-contiguous PTY
output and an exact kitty-escape round-trip. Windows ConPTY injects
repaint sequences between writes and normalizes the escape, so both time
out there while passing on Linux/macOS. Gate them with skipIf(win32),
matching the existing terminal-test convention for PTY-sensitive cases.
* Apply terminal barriers immediately when no writes are pending
A barrier op (snapshot/restore/clear) only needs the sentinel-write gate
to wait out plain writes still parsing in xterm's buffer. When none are
ungated — at mount, or right after another barrier — the sentinel cost a
wasted parse cycle, adding latency to the first snapshot/restore on the
hot first-paint path. Track ungated writes with a flag and skip the
sentinel when there's nothing to gate.
* Fix mobile startup saved-host e2e
* Unskip Windows worker terminal coverage
* Tighten worker coalescing assertion
* Reset terminal ungated writes on unmount
* Fix terminal regressions found in adversarial review
Three issues this PR's terminal changes introduced:
- Worker snapshot could duplicate coalesced output. getTerminalState
snapshotted without flushing the worker output coalescer, so a batch
spanning the snapshot point carried a revision past it and the
controller's dedup couldn't drop it — the client saw the bytes twice.
Flush before snapshotting.
- Relay clients lost backpressure protection. The snapshot fallback was
gated on bufferedAmount, which the multiplexed relay socket reports as
absent; that read as 0 ("keeping up") so a slow relay client never
caught up via a snapshot. Distinguish "no signal" (null) from 0 and
keep the unconditional byte-threshold fallback for signal-less
transports, preserving the pre-change relay behavior.
- Recent-output buffer was no longer a hard cap. A single chunk larger
than the limit was retained whole; slice its tail.
Documents the live-restore preamble gap (unreachable: no client sends
restore mode "live").
* Skip input-mode preamble test on Windows ConPTY
CI confirmed Windows ConPTY normalizes away the kitty keyboard escape
the child writes, so it never reaches the worker's input-mode tracker
and the preamble stays empty — the test times out. ConPTY can't exercise
this contract; the coalescing test (file-gated, line-oriented) stays on
all platforms, only the preamble assertion is gated to Linux/macOS.
250 lines
7.4 KiB
TypeScript
250 lines
7.4 KiB
TypeScript
import type { Route } from "@playwright/test";
|
|
import { expect, type Page } from "../fixtures";
|
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
|
import { wsRoutePatternForPort } from "./daemon-port";
|
|
|
|
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
|
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
|
const REGISTRY_KEY = "@paseo:daemon-registry";
|
|
const E2E_KEY = "@paseo:e2e";
|
|
const STORAGE_SEED_HTML = "<!doctype html><html><body>storage seed</body></html>";
|
|
|
|
interface SavedHostInput {
|
|
serverId: string;
|
|
label: string;
|
|
endpoint: string;
|
|
}
|
|
|
|
export function startupScenario(page: Page) {
|
|
return new StartupScenario(page);
|
|
}
|
|
|
|
class StartupScenario {
|
|
private readonly page: Page;
|
|
private savedHosts: SavedHostInput[] = [];
|
|
private desktopBridge = false;
|
|
private blockedEndpointPorts = new Set<string>();
|
|
private viewport: { width: number; height: number } | null = null;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
withMobileViewport(): this {
|
|
this.viewport = { width: 390, height: 844 };
|
|
return this;
|
|
}
|
|
|
|
withSavedHost(input: SavedHostInput): this {
|
|
this.savedHosts.push(input);
|
|
const port = input.endpoint.match(/:(\d+)$/)?.[1];
|
|
if (port) {
|
|
this.blockedEndpointPorts.add(port);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
withPendingDesktopDaemon(): this {
|
|
this.desktopBridge = true;
|
|
return this;
|
|
}
|
|
|
|
withBlockedPort(port: string): this {
|
|
this.blockedEndpointPorts.add(port);
|
|
return this;
|
|
}
|
|
|
|
async openRoot(): Promise<StartupAssertions> {
|
|
await this.prepare();
|
|
await this.page.goto("/");
|
|
return new StartupAssertions(this.page);
|
|
}
|
|
|
|
async openHostWorkspace(input: {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
}): Promise<StartupAssertions> {
|
|
await this.prepare();
|
|
await this.page.goto(
|
|
`/h/${encodeURIComponent(input.serverId)}/workspace/${encodeURIComponent(input.workspaceId)}`,
|
|
);
|
|
return new StartupAssertions(this.page);
|
|
}
|
|
|
|
private async prepare(): Promise<void> {
|
|
if (this.viewport) {
|
|
await this.page.setViewportSize(this.viewport);
|
|
}
|
|
|
|
if (this.desktopBridge) {
|
|
await installPendingDesktopBridge(this.page);
|
|
}
|
|
|
|
for (const port of this.blockedEndpointPorts) {
|
|
await this.page.routeWebSocket(wsRoutePatternForPort(port), async (ws) => {
|
|
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
|
|
});
|
|
}
|
|
|
|
if (this.savedHosts.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Create same-origin storage without booting the app. If the default host runtime
|
|
// starts first, it can race this scenario's registry override during CI.
|
|
await openStorageSeedDocument(this.page);
|
|
const nowIso = new Date().toISOString();
|
|
const registry = this.savedHosts.map((host) =>
|
|
buildStoredHost({
|
|
serverId: host.serverId,
|
|
endpoint: host.endpoint,
|
|
label: host.label,
|
|
nowIso,
|
|
}),
|
|
);
|
|
const firstHost = registry[0];
|
|
if (!firstHost) {
|
|
throw new Error("Expected at least one startup test host.");
|
|
}
|
|
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
|
|
|
|
await this.page.evaluate(
|
|
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
|
|
const nonce = localStorage.getItem(keys.seedNonce);
|
|
if (!nonce) {
|
|
throw new Error("Expected e2e seed nonce before overriding startup registry.");
|
|
}
|
|
|
|
localStorage.setItem(keys.e2e, "1");
|
|
localStorage.setItem(keys.registry, JSON.stringify(storedRegistry));
|
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences));
|
|
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
|
|
},
|
|
{
|
|
keys: {
|
|
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
|
e2e: E2E_KEY,
|
|
registry: REGISTRY_KEY,
|
|
seedNonce: SEED_NONCE_KEY,
|
|
},
|
|
registry,
|
|
createAgentPreferences,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async function openStorageSeedDocument(page: Page): Promise<void> {
|
|
await page.route(
|
|
"**/*",
|
|
async (route: Route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/html",
|
|
body: STORAGE_SEED_HTML,
|
|
});
|
|
},
|
|
{ times: 1 },
|
|
);
|
|
await page.goto("/");
|
|
}
|
|
|
|
class StartupAssertions {
|
|
private readonly page: Page;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
async expectsSavedHostShell(input: { label: string }): Promise<this> {
|
|
await this.page.getByRole("button", { name: "Open menu", exact: true }).click();
|
|
await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
await expect(this.page.getByRole("button", { name: "Add project" })).toBeVisible();
|
|
await expect(this.page.getByRole("button", { name: "Home" })).toBeVisible();
|
|
await expect(this.page.getByRole("button", { name: "Settings" })).toBeVisible();
|
|
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
|
return this;
|
|
}
|
|
|
|
async expectsNoSavedHostErrorStatus(): Promise<this> {
|
|
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
|
|
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
|
|
return this;
|
|
}
|
|
|
|
async expectsNoLocalServerStartupCopy(): Promise<this> {
|
|
await expect(this.page.getByText("Starting local server...", { exact: true })).toHaveCount(0);
|
|
await expect(this.page.getByText("Connecting to local server...", { exact: true })).toHaveCount(
|
|
0,
|
|
);
|
|
return this;
|
|
}
|
|
|
|
async expectsDesktopDaemonStartup(): Promise<this> {
|
|
await expect(this.page.getByTestId("startup-splash")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
return this;
|
|
}
|
|
|
|
async expectsSidebarHidden(): Promise<this> {
|
|
await expect(this.page.locator('[data-testid="sidebar-settings"]:visible')).toHaveCount(0);
|
|
await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0);
|
|
return this;
|
|
}
|
|
|
|
async expectsNoUndefinedRoute(): Promise<this> {
|
|
await expect(this.page).not.toHaveURL(/\/h\/undefined\/workspace\/undefined/);
|
|
return this;
|
|
}
|
|
}
|
|
|
|
async function installPendingDesktopBridge(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
|
platform: "darwin",
|
|
invoke: async (command: string) => {
|
|
if (command === "start_desktop_daemon") {
|
|
await new Promise(() => {
|
|
// Keep the daemon in the startup phase until the test ends.
|
|
});
|
|
}
|
|
if (command === "desktop_daemon_status") {
|
|
return {
|
|
serverId: "srv_desktop_pending",
|
|
status: "starting",
|
|
listen: null,
|
|
hostname: null,
|
|
pid: null,
|
|
home: "",
|
|
version: null,
|
|
desktopManaged: true,
|
|
error: null,
|
|
};
|
|
}
|
|
if (command === "desktop_daemon_logs") {
|
|
return { logPath: "", contents: "" };
|
|
}
|
|
return null;
|
|
},
|
|
getPendingOpenProject: async () => null,
|
|
events: { on: async () => () => undefined },
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildStoredHost(input: {
|
|
serverId: string;
|
|
endpoint: string;
|
|
label: string;
|
|
nowIso: string;
|
|
}) {
|
|
return buildSeededHost(input);
|
|
}
|
|
|
|
function buildStoredCreateAgentPreferences(serverId: string) {
|
|
return buildCreateAgentPreferences(serverId);
|
|
}
|