Compare commits

...
5 changed files with 228 additions and 0 deletions
@@ -0,0 +1,55 @@
# Android input delay diagnostic
Diagnostic evidence for https://github.com/getpaseo/paseo/issues/4408, not a fix or a CI test.
Observed with the published Android 0.11.4 APK on an Android 16 emulator and a main
daemon at 7c7b6555f17a88cffddf484129cd04ad79704f59. The fixture has 400 active
workspaces sharing one checkout, plus a synthetic 110-turn conversation and a
five-minute mock-provider stream. Clean/dirty transitions emitted 400 workspace
updates each. The JavaScript thread reached 96–100% CPU, and tapping Open menu
did not expose Close sidebar within five seconds. The same process stayed alive.
This combines the many-workspace and streaming reports. It does not isolate which
work causes the delay, reproduce an allocation crash, or establish that the
contributor's byte-counting optimization fixes it. An emulator is not the affected
physical phone. No forced memory pressure, forced garbage collection, or fake
client responses are used. The daemon's existing mock provider supplies synthetic
agent output through the normal transport.
## Run
Use a disposable emulator with the published app installed and agent-device
0.20.3 or later. Preserve its previous state before changing its app installation.
Build the repository's server dependencies first. All commands below run from
the repository root in a disposable worktree.
1. Start `npx tsx packages/app/e2e/mobile/workspace-update-freeze/fixture.ts`.
It writes an isolated home under `.dev/workspace-update-freeze`, starts an
isolated daemon, and exposes it on local TCP port 45151. Do not use an existing
daemon or point this script at real data.
2. Forward that port to the emulator, open the app with an agent-device session,
and add a direct connection to that isolated daemon. Open the sidebar and
verify that the Android reproduction project and workspace rows are visible.
3. Start `npx tsx packages/app/e2e/mobile/workspace-update-freeze/stream.ts`.
Wait for `HISTORY_READY`. The script writes the agent ID to
`.dev/workspace-update-freeze/history-ready` and starts the five-minute stream.
4. Open `paseo://h/diag515a/agent/<agent-id>` with agent-device, using the generated
ID. Dismiss the notification prompt if present. Verify that the agent's text
and Stop agent button are visible, then scroll the conversation.
5. Start `node packages/app/e2e/mobile/workspace-update-freeze/toggle.mjs` in a
separate terminal. It toggles the fixture's tracked file every three seconds
for 90 seconds. `events.json` records workspace updates observed by an actual
subscribed daemon client; initial subscription updates are included.
6. While the toggles and stream are active, run:
```sh
node packages/app/e2e/mobile/workspace-update-freeze/android.mjs <device-session>
```
It taps Open menu and requires Close sidebar within five seconds. The observed
failure was a timeout for Close sidebar while Open menu remained visible.
This asserts an input delay, not an application crash or its root cause.
Stop the fixture with SIGTERM to close its client, proxy, and isolated daemon.
Close the agent-device session and restore the emulator's prior state afterward.
Do not interpret a passing run as disproving the physical-device reports.
@@ -0,0 +1,16 @@
import { execFileSync } from "node:child_process";
const session = process.argv[2];
if (!session) throw new Error("Pass the agent-device session connected to the Android fixture");
function device(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
stdio: "inherit",
timeout: 30_000,
});
}
device("wait", 'label="Open menu"', "5000");
device("press", 'label="Open menu"', "--settle");
device("wait", 'label="Close sidebar"', "5000");
@@ -0,0 +1,107 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { startIsolatedHostDaemon } from "../../support/helpers/isolated-host-daemon";
import { connectDaemonClient } from "../../support/helpers/daemon-client-loader";
import { mkdir, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import path from "node:path";
import net from "node:net";
interface FixtureClient {
connect(): Promise<void>;
close(): Promise<void>;
fetchWorkspaces(options: {
page: { limit: number };
subscribe: Record<string, never>;
}): Promise<{ entries: unknown[] }>;
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
}
async function main() {
process.env.E2E_METRO_PORT = "8098";
const root = path.resolve(".dev/workspace-update-freeze");
const cwd = path.join(root, "repo");
const home = path.join(root, "home");
await mkdir(cwd, { recursive: true });
await mkdir(home + "/projects", { recursive: true });
for (const args of [
["init", "-b", "main"],
["config", "user.name", "Repro"],
["config", "user.email", "repro@example.com"],
])
execFileSync("git", args, { cwd });
await writeFile(cwd + "/README.md", "initial\n");
execFileSync("git", ["add", "README.md"], { cwd });
execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], { cwd });
const date = "2026-03-01T00:00:00.000Z";
const project = {
projectId: "prj_diag515a",
rootPath: cwd,
kind: "git",
displayName: "Android reproduction",
projectKey: null,
customName: null,
customIconRevision: null,
createdAt: date,
updatedAt: date,
archivedAt: null,
};
const workspaces = Array.from({ length: 400 }, (_, i) => ({
workspaceId: `wks_diag515a_${i}`,
projectId: project.projectId,
cwd,
kind: "directory",
displayName: `Workspace ${String(i).padStart(3, "0")}`,
title: null,
branch: null,
worktreeRoot: null,
baseBranch: "main",
isPaseoOwnedWorktree: false,
mainRepoRoot: cwd,
createdAt: date,
updatedAt: date,
archivedAt: null,
autoArchivedChangeRequestUrl: null,
pinnedAt: null,
}));
await writeFile(home + "/projects/projects.json", JSON.stringify([project]));
await writeFile(home + "/projects/workspaces.json", JSON.stringify(workspaces));
const daemon = await startIsolatedHostDaemon("diag515a", { paseoHome: home, preserveHome: true });
const client = await connectDaemonClient<FixtureClient>({
clientIdPrefix: "diag515a",
port: daemon.port,
});
const events: { at: number; payload: unknown }[] = [];
client.subscribeRawMessages((m) => {
if (m.type === "workspace_update") events.push({ at: Date.now(), payload: m.payload });
});
const initial = await client.fetchWorkspaces({ page: { limit: 200 }, subscribe: {} });
console.log("INITIAL", initial.entries.length);
const proxy = net.createServer((socket) => {
const up = net.connect(daemon.port, "127.0.0.1");
socket.pipe(up);
up.pipe(socket);
socket.on("error", () => up.destroy());
up.on("error", () => socket.destroy());
});
await new Promise<void>((resolve) => proxy.listen(45151, "127.0.0.1", resolve));
await writeFile(
root + "/ready.json",
JSON.stringify({ port: daemon.port, cwd, pid: daemon.getPid() }),
);
console.log("READY", daemon.port);
const timer = setInterval(async () => {
await writeFile(root + "/events.json", JSON.stringify(events));
}, 1000);
process.on("SIGTERM", async () => {
clearInterval(timer);
proxy.close();
await client.close();
await daemon.close();
process.exit(0);
});
await new Promise(() => {});
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,41 @@
import type { SeedDaemonClient } from "../../support/helpers/seed-client";
import { connectDaemonClient } from "../../support/helpers/daemon-client-loader";
import { readFile, writeFile } from "node:fs/promises";
async function main() {
const { port, cwd } = JSON.parse(
await readFile(".dev/workspace-update-freeze/ready.json", "utf8"),
);
const client = await connectDaemonClient<SeedDaemonClient>({
clientIdPrefix: "stream515a",
port,
});
try {
const agent = await client.createAgent({
provider: "mock",
cwd,
workspaceId: "wks_diag515a_0",
title: "Android stream reproduction",
modeId: "load-test",
model: "five-minute-stream",
});
await writeFile(".dev/workspace-update-freeze/agent.json", JSON.stringify(agent));
for (let i = 0; i < 110; i++) {
await client.sendAgentMessage(
agent.id,
`history ${i}: emit 1 coalesced agent stream updates`,
);
await client.waitForFinish(agent.id, 15000);
}
console.log("HISTORY_READY", agent.id);
await writeFile(".dev/workspace-update-freeze/history-ready", agent.id);
await client.sendAgentMessage(agent.id, "Stream a realistic long answer with code blocks.");
await client.waitForFinish(agent.id, 360000);
console.log("FINISHED");
} finally {
await client.close();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,9 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const root = path.resolve(".dev/workspace-update-freeze");
const { cwd } = JSON.parse(await readFile(path.join(root, "ready.json"), "utf8"));
for (let index = 0; index < 30; index += 1) {
await writeFile(path.join(cwd, "README.md"), index % 2 ? "changed first\n" : "initial\n");
await new Promise((resolve) => setTimeout(resolve, 3000));
}