Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/daemon/handlers/__tests__/session-doctor-web.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { beforeEach, test, vi } from 'vitest';
import type { AgentBrowserProcessSummary } from '../../../platforms/web/agent-browser-lifecycle.ts';
import { installFakeManagedAgentBrowser } from '../../../platforms/web/__tests__/test-utils.ts';
import type { DoctorCheck } from '../session-doctor-types.ts';

vi.mock('../../../platforms/web/agent-browser-lifecycle.ts', async () => {
const actual = await vi.importActual<
typeof import('../../../platforms/web/agent-browser-lifecycle.ts')
>('../../../platforms/web/agent-browser-lifecycle.ts');
return {
...actual,
inspectManagedAgentBrowserProcesses: vi.fn(),
};
});

const { inspectManagedAgentBrowserProcesses } =
await import('../../../platforms/web/agent-browser-lifecycle.ts');
const { appendWebBrowserLifecycleCheck } = await import('../session-doctor-web.ts');

const mockInspectManagedAgentBrowserProcesses = vi.mocked(inspectManagedAgentBrowserProcesses);

beforeEach(() => {
mockInspectManagedAgentBrowserProcesses.mockReset();
});

test('web doctor lifecycle check reports live managed Chrome process count', async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-doctor-'));
try {
installFakeManagedAgentBrowser(stateDir);
mockInspectManagedAgentBrowserProcesses.mockResolvedValue({
count: 2,
pids: [101, 102],
processes: [
{
process: { pid: 101, command: 'chrome --agent-device-managed-web=abc' },
reason: 'launch-marker',
},
{
process: { pid: 102, command: 'chrome from managed home' },
reason: 'managed-browser-home',
},
],
} satisfies AgentBrowserProcessSummary);

const checks: DoctorCheck[] = [];
await appendWebBrowserLifecycleCheck(checks, stateDir);

assert.equal(checks.length, 1);
assert.equal(checks[0]?.id, 'web-agent-browser-processes');
assert.equal(checks[0]?.status, 'info');
assert.match(checks[0]?.summary ?? '', /2 live agent-device-owned Chrome processes/);
assert.deepEqual(checks[0]?.evidence?.pids, [101, 102]);
assert.deepEqual(checks[0]?.evidence?.matchReasons, ['launch-marker', 'managed-browser-home']);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});

test('web doctor lifecycle check stays informational when managed backend is missing', async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-doctor-missing-'));
try {
const checks: DoctorCheck[] = [];
await appendWebBrowserLifecycleCheck(checks, stateDir);

assert.equal(checks[0]?.status, 'info');
assert.match(checks[0]?.summary ?? '', /not installed/);
assert.equal(mockInspectManagedAgentBrowserProcesses.mock.calls.length, 0);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
50 changes: 50 additions & 0 deletions src/daemon/handlers/session-doctor-web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { inspectManagedAgentBrowserProcesses } from '../../platforms/web/agent-browser-lifecycle.ts';
import { getManagedAgentBrowserStatus } from '../../platforms/web/agent-browser-tool.ts';
import { appendDoctorCheck } from './session-doctor-output.ts';
import type { DoctorCheck } from './session-doctor-types.ts';

export async function appendWebBrowserLifecycleCheck(
checks: DoctorCheck[],
stateDir: string,
): Promise<void> {
appendDoctorCheck(checks, await webBrowserLifecycleCheck(stateDir));
}

async function webBrowserLifecycleCheck(stateDir: string): Promise<DoctorCheck> {
const status = getManagedAgentBrowserStatus({ stateDir });
if (!status.installed) {
return {
id: 'web-agent-browser-processes',
status: 'info',
summary:
'Managed web backend is not installed; no agent-device-owned browser processes counted.',
evidence: { stateDir, installed: false },
};
}
try {
const summary = await inspectManagedAgentBrowserProcesses(status);
return {
id: 'web-agent-browser-processes',
status: summary.count > 0 ? 'info' : 'pass',
summary:
summary.count > 0
? `${summary.count} live agent-device-owned Chrome process${summary.count === 1 ? '' : 'es'} detected.`
: 'No live agent-device-owned Chrome processes detected.',
evidence: {
stateDir,
installed: true,
count: summary.count,
pids: summary.pids,
matchReasons: summary.processes.map((match) => match.reason),
},
};
} catch (error) {
return {
id: 'web-agent-browser-processes',
status: 'info',
summary: 'Could not inspect live agent-device-owned Chrome processes.',
hint: 'Run doctor again from a shell with permission to inspect local processes.',
evidence: { stateDir, error: error instanceof Error ? error.message : String(error) },
};
}
}
6 changes: 5 additions & 1 deletion src/daemon/handlers/session-doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
hasCachedAppleRunnerArtifact,
prewarmAppleRunnerCache,
} from '../../platforms/apple/core/runner/runner-client.ts';
import { appendWebBrowserLifecycleCheck } from './session-doctor-web.ts';

export async function handleDoctorCommand(params: {
req: DaemonRequest;
Expand Down Expand Up @@ -71,6 +72,7 @@ export async function handleDoctorCommand(params: {
inventory,
options,
session,
stateDir,
});
await appendIosRunnerWarmupCheck(checks, appCheckDevice ?? resolveWarmupSimulator(inventory));
return doctorResponse(checks, options, { device: appCheckDevice, includeMetro: true, inventory });
Expand Down Expand Up @@ -139,8 +141,9 @@ async function appendLocalDoctorChecks(params: {
inventory: DoctorDeviceInventory | undefined;
options: DoctorOptions;
session: SessionState | undefined;
stateDir: string;
}): Promise<DeviceInfo | undefined> {
const { checks, inventory, options, session, androidAdbExecutor } = params;
const { checks, inventory, options, session, androidAdbExecutor, stateDir } = params;
const appCheckDevice =
session?.device ?? resolveDoctorDeviceForAppCheck(checks, inventory, options.targetApp);
if (appCheckDevice) {
Expand All @@ -154,6 +157,7 @@ async function appendLocalDoctorChecks(params: {
if (options.shouldProbeMetro) {
appendDoctorCheck(checks, await probeMetro(options.metroHost, options.metroPort, options.kind));
}
await appendWebBrowserLifecycleCheck(checks, stateDir);
return appCheckDevice;
}

Expand Down
17 changes: 14 additions & 3 deletions src/daemon/request-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
webProvider:
webProvider ??
(shouldUseDefaultWebProvider(lockedScope)
? createDefaultWebProvider(stateDir)
? createDefaultWebProvider(stateDir, sessionStore)
: undefined),
appLogProvider,
recordingProvider,
Expand Down Expand Up @@ -244,14 +244,25 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
}

const createDefaultWebProvider =
(stateDir: string | undefined): WebProviderResolver =>
(stateDir: string | undefined, sessionStore: SessionStore): WebProviderResolver =>
({ req, session }) =>
createAgentBrowserWebProvider({ session: session?.name ?? req.session, stateDir });
createAgentBrowserWebProvider({
session: session?.name ?? req.session,
stateDir,
openWebSessionNames: () => openWebSessionNames(sessionStore),
});

function shouldUseDefaultWebProvider(scope: LockedRequestScope): boolean {
return scope.existingSession?.device.platform === 'web' || scope.req.flags?.platform === 'web';
}

function openWebSessionNames(sessionStore: SessionStore): string[] {
return sessionStore
.toArray()
.filter((session) => session.device.platform === 'web')
.map((session) => session.name);
}

function unauthorizedResponse(): DaemonResponse {
return {
ok: false,
Expand Down
66 changes: 66 additions & 0 deletions src/daemon/server/daemon-runtime-web-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { beforeEach, test, vi } from 'vitest';

const { cleanupManagedAgentBrowserOrphansMock } = vi.hoisted(() => ({
cleanupManagedAgentBrowserOrphansMock: vi.fn(),
}));

vi.mock('../../platforms/web/agent-browser-lifecycle.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../platforms/web/agent-browser-lifecycle.ts')>();
return {
...actual,
cleanupManagedAgentBrowserOrphans: cleanupManagedAgentBrowserOrphansMock,
};
});

import { WEB_DESKTOP_DEVICE } from '../../__tests__/test-utils/index.ts';
import { SessionStore } from '../session-store.ts';
import { cleanupWebBrowserOrphansForDaemonStartup } from './daemon-runtime.ts';
import { installFakeManagedAgentBrowser } from '../../platforms/web/__tests__/test-utils.ts';

const mockCleanupManagedAgentBrowserOrphans = vi.mocked(cleanupManagedAgentBrowserOrphansMock);

beforeEach(() => {
mockCleanupManagedAgentBrowserOrphans.mockReset();
});

test('daemon-startup web cleanup passes open web sessions to the reaper', async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-daemon-cleanup-'));
try {
installFakeManagedAgentBrowser(stateDir);
const sessionStore = new SessionStore(path.join(stateDir, 'sessions'));
sessionStore.set('web-session', {
name: 'web-session',
device: WEB_DESKTOP_DEVICE,
createdAt: Date.now(),
actions: [],
});

await cleanupWebBrowserOrphansForDaemonStartup({ stateDir, sessionStore });

assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls.length, 1);
assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls[0]?.[1], 'daemon-startup');
assert.deepEqual(mockCleanupManagedAgentBrowserOrphans.mock.calls[0]?.[2], {
openWebSessionNames: ['web-session'],
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});

test('daemon-startup web cleanup does not run when the managed backend is absent', async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-daemon-cleanup-'));
try {
const sessionStore = new SessionStore(path.join(stateDir, 'sessions'));

await cleanupWebBrowserOrphansForDaemonStartup({ stateDir, sessionStore });

assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls.length, 0);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
29 changes: 29 additions & 0 deletions src/daemon/server/daemon-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
import { prewarmPngWorker, terminatePngWorker } from '../../utils/png-worker-client.ts';
import { sleep } from '../../utils/timeouts.ts';
import { setRunnerLeaseOwnerStateDir } from '../../platforms/apple/core/runner/runner-lease.ts';
import { cleanupManagedAgentBrowserOrphans } from '../../platforms/web/agent-browser-lifecycle.ts';
import { getManagedAgentBrowserStatus } from '../../platforms/web/agent-browser-tool.ts';

const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000;
const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000;
Expand Down Expand Up @@ -216,6 +218,7 @@ export async function startDaemonRuntime(
let socketPort: number | undefined;
let httpPort: number | undefined;
try {
await cleanupWebBrowserOrphansForDaemonStartup({ stateDir: baseDir, sessionStore });
const opened = await openDaemonServers();
servers = opened.servers;
socketPort = opened.socketPort;
Expand Down Expand Up @@ -299,3 +302,29 @@ export async function startDaemonRuntime(
token,
};
}

export async function cleanupWebBrowserOrphansForDaemonStartup(params: {
stateDir: string;
sessionStore: SessionStore;
}): Promise<void> {
const status = getManagedAgentBrowserStatus({ stateDir: params.stateDir });
if (!status.installed) return;
try {
await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', {
openWebSessionNames: openWebSessionNames(params.sessionStore),
});
} catch (error) {
emitDiagnostic({
level: 'warn',
phase: 'web_agent_browser_orphan_cleanup_failed',
data: { error: error instanceof Error ? error.message : String(error) },
});
}
}

function openWebSessionNames(sessionStore: SessionStore): string[] {
return sessionStore
.toArray()
.filter((session) => session.device.platform === 'web')
.map((session) => session.name);
}
Loading
Loading