Skip to content
Open
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
17 changes: 17 additions & 0 deletions packages/capture-kit/src/png-worker-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
decodePngAsync,
decodeScreenshotImageAsync,
encodePngAsync,
retainPngWorker,
terminatePngWorker,
transcodeScreenshotToPngAsync,
} from './png-worker-client.ts';
Expand Down Expand Up @@ -225,3 +226,19 @@ test('decodeScreenshotImageAsync rejects a container it cannot read with the can
},
);
});

test('the worker serves every holder and stops only when the last one lets go', async () => {
const first = retainPngWorker();
const second = retainPngWorker();
const encoded = PNG.sync.write(buildPatternPng(5, 4, 2));

await first();
await first();
const stillServing = await runOnWorkerThread('decode after one release', () =>
decodePngAsync(encoded, 'fixture'),
);
assert.equal(stillServing.width, 5);

await second();
assert.equal(process.getActiveResourcesInfo().includes('MessagePort'), false);
});
18 changes: 16 additions & 2 deletions packages/capture-kit/src/png-worker-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,23 @@ async function runPngJob<Kind extends PngWorkerJobKind>(
}
}

/** Daemon startup hook: spawns the worker before the first screenshot job. */
export function prewarmPngWorker(): void {
let pngWorkerHolds = 0;

/**
* Daemon startup hook: spawns the worker before the first screenshot job and holds it for the
* caller. The worker is shared by every runtime in the process, so it stops when the last holder
* lets go; the returned release is idempotent.
*/
export function retainPngWorker(): () => Promise<void> {
obtainWorker();
pngWorkerHolds += 1;
Comment thread
thymikee marked this conversation as resolved.
let released = false;
return async () => {
if (released) return;
released = true;
pngWorkerHolds -= 1;
if (pngWorkerHolds === 0) await terminatePngWorker();
};
}

/** Stops the worker thread (used by tests and shutdown); later calls respawn it. */
Expand Down
10 changes: 6 additions & 4 deletions packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,10 @@ export type AppleApplicationTools = Readonly<{
applyRuntimeHints(device: DeviceInfo, input: RuntimeHintsApplicationInput): Promise<void>;
clearRuntimeHints(device: DeviceInfo, input: RuntimeHintsApplicationInput): Promise<void>;
dismissCloseAlerts(device: DeviceInfo, input: CloseApplicationFinalizationInput): Promise<void>;
detachRunnerSessionsForShutdown(): Promise<void>;
finalizeRunnerSessionsForShutdown(): Promise<void>;
/** The devices whose runner was handed to the next daemon instead of stopped. */
detachRunnerSessionsForShutdown(): Promise<readonly string[]>;
/** The devices whose runner this daemon stopped. */
finalizeRunnerSessionsForShutdown(): Promise<readonly string[]>;
}>;

/** Lazy Android mechanics used by the Android package's own lifecycle binding. */
Expand Down Expand Up @@ -413,6 +415,6 @@ export type AndroidApplicationTools = Readonly<{
/** Gateway-coordinated durable phases whose concrete mechanics stay package-owned. */
export type ApplicationLifecycleResourceLifecycle = Readonly<{
recoverStartupResources(input: Readonly<{ stateDir: string }>): Promise<void>;
detachForDaemonShutdown(): Promise<void>;
finalizeDaemonShutdown(): Promise<void>;
detachForDaemonShutdown(): Promise<readonly string[]>;
finalizeDaemonShutdown(): Promise<readonly string[]>;
}>;
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export function applicationLifecycleResourceFixture(
): ApplicationLifecycleResourceLifecycle {
return Object.freeze({
recoverStartupResources: vi.fn(async () => {}),
detachForDaemonShutdown: vi.fn(async () => {}),
finalizeDaemonShutdown: vi.fn(async () => {}),
detachForDaemonShutdown: vi.fn(async (): Promise<readonly string[]> => []),
finalizeDaemonShutdown: vi.fn(async (): Promise<readonly string[]> => []),
...overrides,
});
}
Expand Down Expand Up @@ -59,6 +59,7 @@ export function daemonRuntimeDepsFixture(
return async (input) => {
const resolved = typeof overrides === 'function' ? overrides(input) : overrides;
return Object.freeze({
runtimeStates: [],
ownedProcessRecords: createOwnedProcessRecordStore({
stateDir: input.baseDir,
sessionsDir: input.sessionsDir,
Expand Down
25 changes: 22 additions & 3 deletions packages/daemon/src/core/interactor-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { RuntimeStateHandle } from '@agent-device/host-kit/request';
import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
Expand Down Expand Up @@ -27,17 +29,34 @@ const NO_INTERACTOR_RESOLUTION: InteractorResolution = {
},
};

let installedInteractorResolution: InteractorResolution = NO_INTERACTOR_RESOLUTION;
type InteractorResolutionState = { resolution: InteractorResolution };

const interactorResolutionStorage = new AsyncLocalStorage<InteractorResolutionState>();
const standaloneInteractorResolution: InteractorResolutionState = {
resolution: NO_INTERACTOR_RESOLUTION,
};
const interactorResolutionState = (): InteractorResolutionState =>
interactorResolutionStorage.getStore() ?? standaloneInteractorResolution;

/** The resolution one daemon runtime owns; the runtime installs its composition's into it. */
export function createInteractorResolutionRuntimeState(): RuntimeStateHandle {
const owned: InteractorResolutionState = { resolution: NO_INTERACTOR_RESOLUTION };
return {
run: (fn) => interactorResolutionStorage.run(owned, fn),
enter: () => interactorResolutionStorage.enterWith(owned),
dispose: () => {},
};
}

/**
* Root composition's installation point, beside the provider-device admission that shares the
* same request-scoped provider scope: one change names both. The interactor graph stays behind
* the module root composition imports, and the daemon never loads it itself.
*/
export function installInteractorResolution(resolution: InteractorResolution): void {
installedInteractorResolution = resolution;
interactorResolutionState().resolution = resolution;
}

export function interactorResolution(): InteractorResolution {
return installedInteractorResolution;
return interactorResolutionState().resolution;
}
25 changes: 22 additions & 3 deletions packages/daemon/src/core/provider-device-admission.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { RuntimeStateHandle } from '@agent-device/host-kit/request';
import type { DeviceInfo } from '@agent-device/kernel/device';

/**
Expand All @@ -18,17 +20,34 @@ const NO_PROVIDER_DEVICE_ADMISSION: ProviderDeviceAdmission = {
isActive: () => false,
};

let installedProviderDeviceAdmission: ProviderDeviceAdmission = NO_PROVIDER_DEVICE_ADMISSION;
type ProviderAdmissionState = { admission: ProviderDeviceAdmission };

const providerAdmissionStorage = new AsyncLocalStorage<ProviderAdmissionState>();
const standaloneProviderAdmission: ProviderAdmissionState = {
admission: NO_PROVIDER_DEVICE_ADMISSION,
};
const providerAdmissionState = (): ProviderAdmissionState =>
providerAdmissionStorage.getStore() ?? standaloneProviderAdmission;

/** The admission one daemon runtime owns; the runtime installs its composition's into it. */
export function createProviderDeviceAdmissionRuntimeState(): RuntimeStateHandle {
const owned: ProviderAdmissionState = { admission: NO_PROVIDER_DEVICE_ADMISSION };
return {
run: (fn) => providerAdmissionStorage.run(owned, fn),
enter: () => providerAdmissionStorage.enterWith(owned),
dispose: () => {},
};
}

/**
* Root composition's installation point. The provider runtime scope is ambient per request, so
* this is the one place the daemon names where the fact comes from, and the daemon never reads
* provider runtime internals itself.
*/
export function installProviderDeviceAdmission(admission: ProviderDeviceAdmission): void {
installedProviderDeviceAdmission = admission;
providerAdmissionState().admission = admission;
}

export function isActiveProviderDevice(device: DeviceInfo): boolean {
return installedProviderDeviceAdmission.isActive(device);
return providerAdmissionState().admission.isActive(device);
}
3 changes: 1 addition & 2 deletions packages/daemon/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
export { startDaemonRuntime } from './server/http/daemon-runtime.ts';
export { daemonStartupExitCode, startDaemonRuntime } from './server/http/daemon-runtime.ts';
export type {
ComposeDaemonRuntimeDeps,
DaemonRuntimeController,
DaemonRuntimeDeps,
DaemonRuntimeDepsInput,
} from './server/http/daemon-runtime.ts';
export type { DaemonInvokeFn, DaemonRequest } from './core/daemon-request.ts';
export type { DaemonPlatformServices } from './core/platform-services.ts';
Expand Down
32 changes: 32 additions & 0 deletions packages/daemon/src/resources/artifact-tracking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import fs from 'node:fs';
import path from 'node:path';
import { expect, test } from 'vitest';
import { mkdtempForTestSync } from '@agent-device/test-kit';
import {
createArtifactTrackingRuntimeState,
trackDownloadableArtifact,
trackUploadedArtifact,
} from './artifact-tracking.ts';

test('disposing a runtime removes the temporary files it still tracks', () => {
const root = mkdtempForTestSync('agent-device-artifact-dispose-');
const artifactPath = path.join(root, 'artifact.txt');
const uploadDir = path.join(root, 'upload');
fs.writeFileSync(artifactPath, 'artifact');
fs.mkdirSync(uploadDir);
fs.writeFileSync(path.join(uploadDir, 'payload'), 'payload');
const runtime = createArtifactTrackingRuntimeState();

runtime.run(() => {
trackDownloadableArtifact({
artifactPath,
artifactType: 'runtime-artifact',
fileName: 'artifact.txt',
});
trackUploadedArtifact({ artifactPath: path.join(uploadDir, 'payload'), tempDir: uploadDir });
});
runtime.dispose();

expect(fs.existsSync(uploadDir)).toBe(false);
expect(fs.existsSync(artifactPath)).toBe(false);
});
78 changes: 56 additions & 22 deletions packages/daemon/src/resources/artifact-tracking.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { RuntimeStateHandle } from '@agent-device/host-kit/request';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
Expand Down Expand Up @@ -44,7 +46,43 @@ type ArtifactEntry = {
timer: ReturnType<typeof setTimeout>;
};

const pendingArtifacts = new Map<string, ArtifactEntry>();
type ArtifactTrackingState = {
artifacts: Map<string, ArtifactEntry>;
uploads: Map<string, UploadEntry>;
};

const artifactTrackingStorage = new AsyncLocalStorage<ArtifactTrackingState>();
const createArtifactTrackingState = (): ArtifactTrackingState => ({
artifacts: new Map(),
uploads: new Map(),
});
const standaloneArtifactTracking = createArtifactTrackingState();
const artifactTracking = (): ArtifactTrackingState =>
artifactTrackingStorage.getStore() ?? standaloneArtifactTracking;
const pendingArtifacts = () => artifactTracking().artifacts;
const pendingUploads = () => artifactTracking().uploads;

/** The artifacts and uploads one daemon runtime tracks; disposing it stops their expiry timers. */
export function createArtifactTrackingRuntimeState(): RuntimeStateHandle {
const owned = createArtifactTrackingState();
return {
run: (fn) => artifactTrackingStorage.run(owned, fn),
enter: () => artifactTrackingStorage.enterWith(owned),
dispose: () => {
for (const entry of owned.artifacts.values()) {
clearTimeout(entry.timer);
cleanupDirectoryArchive(entry);
if (entry.deleteAfterDownload) removeBestEffort(entry.artifactPath);
}
for (const entry of owned.uploads.values()) {
clearTimeout(entry.timer);
removeBestEffort(entry.tempDir);
}
owned.artifacts.clear();
Comment thread
thymikee marked this conversation as resolved.
owned.uploads.clear();
},
};
}

/** What a finished request registers for each artifact it hands back to the client. */
export type DownloadableArtifactRegistration = {
Expand Down Expand Up @@ -73,7 +111,7 @@ export function trackDownloadableArtifact(
cleanupDownloadableArtifact(artifactId);
}, ARTIFACT_CLEANUP_TIMEOUT_MS);
timer.unref();
pendingArtifacts.set(artifactId, {
pendingArtifacts().set(artifactId, {
artifactPath: params.artifactPath,
tenantId: params.tenantId,
artifactType: params.artifactType,
Expand Down Expand Up @@ -105,7 +143,7 @@ export async function listDownloadableArtifacts(
tenantId?: string,
): Promise<DaemonArtifactInventoryEntry[]> {
const artifacts: DaemonArtifactInventoryEntry[] = [];
for (const [id, entry] of pendingArtifacts) {
for (const [id, entry] of pendingArtifacts()) {
if (!canReadArtifact(entry, tenantId)) continue;
let stat: fs.Stats;
try {
Expand Down Expand Up @@ -148,7 +186,7 @@ function readDownloadableArtifactEntry(
tenantId: string | undefined,
): { entry: ArtifactEntry; stat: fs.Stats } {
const entry = requireTenantOwnedEntry(
pendingArtifacts,
pendingArtifacts(),
artifactId,
tenantId,
DOWNLOADABLE_ARTIFACT_RESOURCE,
Expand Down Expand Up @@ -249,7 +287,7 @@ async function createDirectoryArchive(
sizeBytes: fs.statSync(archivePath).size,
};
// The artifact may be consumed or expire while tar is still archiving.
if (pendingArtifacts.get(artifactId) !== entry) {
if (pendingArtifacts().get(artifactId) !== entry) {
fs.rmSync(tempDir, { recursive: true, force: true });
throw expiredTenantOwnedEntryError(DOWNLOADABLE_ARTIFACT_RESOURCE, artifactId);
}
Expand Down Expand Up @@ -294,28 +332,26 @@ function hasUnsafeFilenameCharacter(fileName: string): boolean {
}

export function cleanupDownloadableArtifact(artifactId: string): void {
const entry = pendingArtifacts.get(artifactId);
const entry = pendingArtifacts().get(artifactId);
if (!entry) return;
clearTimeout(entry.timer);
pendingArtifacts.delete(artifactId);
pendingArtifacts().delete(artifactId);
cleanupDirectoryArchive(entry);
if (!entry.deleteAfterDownload) return;
if (entry.deleteAfterDownload) removeBestEffort(entry.artifactPath);
}

function removeBestEffort(targetPath: string): void {
Comment thread
thymikee marked this conversation as resolved.
try {
fs.rmSync(entry.artifactPath, { recursive: true, force: true });
fs.rmSync(targetPath, { recursive: true, force: true });
} catch {
// best-effort cleanup only
}
}

function cleanupDirectoryArchive(entry: ArtifactEntry): void {
if (!entry.directoryArchive) return;
try {
fs.rmSync(entry.directoryArchive.tempDir, { recursive: true, force: true });
} catch {
// best-effort cleanup only
} finally {
entry.directoryArchive = undefined;
}
removeBestEffort(entry.directoryArchive.tempDir);
entry.directoryArchive = undefined;
}

// --- Upload artifact tracking ---
Expand All @@ -334,8 +370,6 @@ type UploadEntry = {
timer: ReturnType<typeof setTimeout>;
};

const pendingUploads = new Map<string, UploadEntry>();

export function trackUploadedArtifact(params: {
artifactPath: string;
tempDir: string;
Expand All @@ -346,7 +380,7 @@ export function trackUploadedArtifact(params: {
cleanupUploadedArtifact(uploadId);
}, UPLOAD_CLEANUP_TIMEOUT_MS);
timer.unref();
pendingUploads.set(uploadId, {
pendingUploads().set(uploadId, {
artifactPath: params.artifactPath,
tempDir: params.tempDir,
tenantId: params.tenantId,
Expand All @@ -357,7 +391,7 @@ export function trackUploadedArtifact(params: {

export function prepareUploadedArtifact(uploadId: string, tenantId?: string): string {
const entry = requireTenantOwnedEntry(
pendingUploads,
pendingUploads(),
uploadId,
tenantId,
UPLOADED_ARTIFACT_RESOURCE,
Expand All @@ -367,9 +401,9 @@ export function prepareUploadedArtifact(uploadId: string, tenantId?: string): st
}

export function cleanupUploadedArtifact(uploadId: string): void {
const entry = pendingUploads.get(uploadId);
const entry = pendingUploads().get(uploadId);
if (!entry) return;
clearTimeout(entry.timer);
pendingUploads.delete(uploadId);
pendingUploads().delete(uploadId);
fs.rmSync(entry.tempDir, { recursive: true, force: true });
}
Loading
Loading