diff --git a/packages/app/e2e/mobile/workspace-update-freeze/README.md b/packages/app/e2e/mobile/workspace-update-freeze/README.md new file mode 100644 index 0000000000..2141004bec --- /dev/null +++ b/packages/app/e2e/mobile/workspace-update-freeze/README.md @@ -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/` 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 + ``` + + 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. diff --git a/packages/app/e2e/mobile/workspace-update-freeze/android.mjs b/packages/app/e2e/mobile/workspace-update-freeze/android.mjs new file mode 100644 index 0000000000..c3c658917c --- /dev/null +++ b/packages/app/e2e/mobile/workspace-update-freeze/android.mjs @@ -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"); diff --git a/packages/app/e2e/mobile/workspace-update-freeze/fixture.ts b/packages/app/e2e/mobile/workspace-update-freeze/fixture.ts new file mode 100644 index 0000000000..5a20421d11 --- /dev/null +++ b/packages/app/e2e/mobile/workspace-update-freeze/fixture.ts @@ -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; + close(): Promise; + fetchWorkspaces(options: { + page: { limit: number }; + subscribe: Record; + }): 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({ + 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((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); +}); diff --git a/packages/app/e2e/mobile/workspace-update-freeze/stream.ts b/packages/app/e2e/mobile/workspace-update-freeze/stream.ts new file mode 100644 index 0000000000..28cb47d5d1 --- /dev/null +++ b/packages/app/e2e/mobile/workspace-update-freeze/stream.ts @@ -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({ + 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); +}); diff --git a/packages/app/e2e/mobile/workspace-update-freeze/toggle.mjs b/packages/app/e2e/mobile/workspace-update-freeze/toggle.mjs new file mode 100644 index 0000000000..06be7206d2 --- /dev/null +++ b/packages/app/e2e/mobile/workspace-update-freeze/toggle.mjs @@ -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)); +}