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
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ test('daemon startup awaits app-log recovery after acquiring the lock and before
const acquiredLock = source.indexOf("if (acquisition.status !== 'acquired')");
const legacyRecovery = source.indexOf('await deps.ownerLifecycle.recoverLegacyAppLogMarkers(');
const recovery = source.indexOf('await recoverAppLogResourcesAfterDaemonLock(');
const openedServers = source.indexOf('const opened = await openDaemonServers()');
const openedServers = source.indexOf('await openDaemonServers(');

expect(acquiredLock).toBeGreaterThanOrEqual(0);
expect(legacyRecovery).toBeGreaterThan(acquiredLock);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import fs from 'node:fs';
import { setTimeout as delay } from 'node:timers/promises';
import { lifecycleDeviceRuntimeGateway } from '../../__tests__/support/test-device-runtime-gateway.ts';
import { expect, test, vi } from 'vitest';
import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures';
import type { RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots';
import type { ProviderDeviceRuntime } from '@agent-device/contracts/device';
import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution';
import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit';
import { clearAndroidObservationFixture } from '../../__tests__/support/android-observation-fixture.ts';
import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts';
import {
daemonRuntimeDepsFixture,
applicationLifecycleResourceFixture,
platformOwnerLifecycleFixture,
pluginRegistryFixture,
} from '../../__tests__/support/daemon-runtime-deps-fixture.ts';
import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts';
Expand Down Expand Up @@ -105,6 +110,7 @@ const cleanReport: ShutdownReport = {
requests: { drained: 0, abandoned: 0 },
runners: { stopped: [], handedOff: [] },
sessions: { closed: 0, failed: 0 },
platformOwners: 'closed',
plugins: { disposed: [], failed: [], pending: [] },
};

Expand Down Expand Up @@ -163,3 +169,49 @@ test('the executable writes unfinished work to stderr after a close, and a clean
expect(unfinished).toEqual([expect.stringMatching(/1 request\(s\) ignored cancellation/)]);
expect(failed.join('')).toContain('Daemon shutdown error: close blew up');
});

test('unfinished platform owner shutdown is bounded, reports pending and retains ownership', async () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-bounded-owner-');
let finishOwner!: () => void;
const ownerClosed = new Promise<void>((resolve) => {
finishOwner = resolve;
});
const clearLock = vi.fn(async () => {});
const pluginShutdown = vi.fn(async () => []);
const disposeState = vi.fn();
const state: RuntimeStateHandle = { run: (fn) => fn(), enter: () => {}, dispose: disposeState };
const runtime = await startDaemonRuntime({
host: hostFor(stateDir),
...silent,
shutdownBudgets: { ownerMs: 20 },
composeDeps: daemonRuntimeDepsFixture({
deviceRuntimeGateway: {
...lifecycleDeviceRuntimeGateway,
applicationLifecycle: applicationLifecycleResourceFixture(),
shutdown: () => ownerClosed,
},
ownerLifecycle: platformOwnerLifecycleFixture({ clearDaemonLockConfiguration: clearLock }),
shutdownProviderRuntimes: pluginShutdown,
runtimeStates: [state],
}),
});
const closing = runtime.close();
try {
const report = await Promise.race([closing, delay(1_000, null)]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This race leaves its one-second timers/promises timer running after closing wins, adding an unnecessary one-second live handle to every passing test run and potentially delaying worker shutdown. Abort the timeout when the race settles.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/daemon/src/server/http/daemon-runtime-bounded-close.test.ts, line 200:

<comment>This race leaves its one-second `timers/promises` timer running after `closing` wins, adding an unnecessary one-second live handle to every passing test run and potentially delaying worker shutdown. Abort the timeout when the race settles.</comment>

<file context>
@@ -163,3 +169,49 @@ test('the executable writes unfinished work to stderr after a close, and a clean
+  });
+  const closing = runtime.close();
+  try {
+    const report = await Promise.race([closing, delay(1_000, null)]);
+    expect(report).not.toBeNull();
+    expect(report!.platformOwners).toBe('pending');
</file context>
Suggested change
const report = await Promise.race([closing, delay(1_000, null)]);
const report = await (async () => {
const timeout = new AbortController();
try {
return await Promise.race([closing, delay(1_000, null, { signal: timeout.signal })]);
} finally {
timeout.abort();
}
})();

expect(report).not.toBeNull();
expect(report!.platformOwners).toBe('pending');
await expect(runtime.closed).resolves.toBe(report);
expect(pluginShutdown).toHaveBeenCalledOnce();
expect(clearLock).not.toHaveBeenCalled();
expect(disposeState).not.toHaveBeenCalled();
const paths = resolveDaemonPaths(stateDir);
expect(fs.existsSync(paths.lockPath)).toBe(true);
expect(fs.existsSync(paths.infoPath)).toBe(true);
expect(describeUnfinishedShutdownWork(report!)).toEqual([
expect.stringContaining('platform owners were still shutting down'),
]);
} finally {
finishOwner();
await closing;
}
});
63 changes: 63 additions & 0 deletions packages/daemon/src/server/http/daemon-runtime-close.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import * as transport from './transport.ts';
import { expect, test, vi } from 'vitest';
import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution';
import type { ProviderDeviceRuntime } from '@agent-device/contracts/device';
Expand Down Expand Up @@ -64,6 +66,7 @@ test('close is idempotent and every caller receives the one report', async () =>
handedOff: ['handed-off-device'],
});
expect(first.sessions).toEqual({ closed: 0, failed: 0 });
expect(first.platformOwners).toBe('closed');
expect(first.providerReleases).toEqual({ completed: [], pending: [], unknown: [] });
expect(first.plugins.disposed).toEqual(['stops']);
expect(first.plugins.failed).toEqual([
Expand Down Expand Up @@ -139,3 +142,63 @@ test('a shutdown step that rejects still releases the registration, lock and pro
expect(fs.existsSync(paths.lockPath)).toBe(false);
expect(fs.existsSync(paths.infoPath)).toBe(false);
});

test('failed HTTP setup rolls back an already listening socket', async () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-partial-listen-');
const host = hostFor(stateDir);
const createSocketServer = transport.createSocketServer;
let acquired: ReturnType<typeof createSocketServer> | undefined;
const spy = vi.spyOn(transport, 'createSocketServer').mockImplementation((handler) => {
acquired = createSocketServer(handler);
return acquired;
});
try {
await expect(
startDaemonRuntime({
host: createTestHostEnvironment({
env: {
...host.env,
AGENT_DEVICE_DAEMON_SERVER_MODE: 'dual',
AGENT_DEVICE_HTTP_AUTH_HOOK: path.join(stateDir, 'missing-hook.mjs'),
},
}),
...silent,
composeDeps: daemonRuntimeDepsFixture(),
}),
).rejects.toMatchObject({ details: { hookPath: path.join(stateDir, 'missing-hook.mjs') } });
expect(acquired).toBeDefined();
expect(acquired!.listening).toBe(false);
expect(fs.existsSync(resolveDaemonPaths(stateDir).lockPath)).toBe(false);
} finally {
spy.mockRestore();
acquired?.close();
}
});

test('a relative HTTP auth hook uses the runtime host directory', async () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-host-auth-');
fs.writeFileSync(
path.join(stateDir, 'auth-hook.mjs'),
"export default () => ({ ok: false, message: 'host hook refused' });",
);
const runtime = await startDaemonRuntime({
host: createTestHostEnvironment({
cwd: stateDir,
env: { ...hostFor(stateDir).env, AGENT_DEVICE_HTTP_AUTH_HOOK: './auth-hook.mjs' },
}),
...silent,
composeDeps: daemonRuntimeDepsFixture(),
});
try {
const response = await fetch(
`http://127.0.0.1:${runtime.httpPort}/sessions/default/requests/abc123/diagnostics`,
{
headers: { authorization: `Bearer ${runtime.token}` },
},
);
expect(response.status).toBe(401);
expect(await response.json()).toMatchObject({ error: 'host hook refused' });
} finally {
await runtime.close();
}
});
Loading
Loading