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
1 change: 0 additions & 1 deletion oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ const LEGACY_FILE_LINE_CAPS = {
'test/integration/provider-scenarios/android-lifecycle.test.ts': 1227,
'packages/host-kit/src/internal/exec.ts': 1032,
'packages/react-native/src/metro/client-metro.ts': 1123,
'src/cli/commands/connection-runtime.ts': 1019,
};

export default defineConfig({
Expand Down
34 changes: 34 additions & 0 deletions packages/client/src/__tests__/lease-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,40 @@ test('allocate sends the lease id named on the call, never the configured one',
assert.equal(lease.leaseId, 'abcdef0123456789');
});

test.each([
{ operation: 'allocate', reused: false, created: true },
{ operation: 'allocate', reused: true, created: false },
{ operation: 'heartbeat', reused: false, created: undefined },
{ operation: 'heartbeat', reused: true, created: undefined },
] as const)(
'$operation reads creation evidence only from an allocate response (reused=$reused)',
async ({ operation, reused, created }) => {
const client = createAgentDeviceClient(
{},
{
transport: async () => ({
ok: true,
data: {
reused,
lease: {
leaseId: 'lease-a',
tenantId: 'tenant-a',
runId: 'run-a',
backend: 'ios-instance',
},
},
}),
},
);
const lease = await client.leases[operation]({
tenant: 'tenant-a',
runId: 'run-a',
leaseId: 'lease-a',
});
assert.equal(lease.created, created);
},
);

for (const operation of ['allocate', 'heartbeat'] as const) {
const validLease = {
leaseId: 'lease-a',
Expand Down
17 changes: 10 additions & 7 deletions packages/client/src/lease-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ export function createLeaseClient(
const control = async (positionals: string[], options?: InternalRequestOptions) =>
await execute(INTERNAL_COMMANDS.humanControl, positionals, options);
return {
allocate: async (options) =>
normalizeLease(
await execute(INTERNAL_COMMANDS.leaseAllocate, [], {
...options,
leaseId: options.leaseId,
}),
),
allocate: async (options) => {
const data = await execute(INTERNAL_COMMANDS.leaseAllocate, [], {
...options,
leaseId: options.leaseId,
});
return {
...normalizeLease(data),
...(typeof data.reused === 'boolean' ? { created: !data.reused } : {}),
};
},
heartbeat: async (options) =>
normalizeLease(await execute(INTERNAL_COMMANDS.leaseHeartbeat, [], options)),
release: async (options) => {
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/client-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export type Lease = {
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
/**
Comment thread
thymikee marked this conversation as resolved.
Resolved
* What an allocate answered: `true` when this request created the lease, `false` on reuse.
* Only allocate answers this; heartbeats and older daemons omit creation evidence.
* A discarded allocate response can be released unless `created` is false.
* Rollback after binding requires `created` to be true.
*/
created?: boolean;
/**
* The device granted for a request that named a device spec, written by the backend that
* honoured the spec. A daemon refuses a spec instead, so only such a backend sets it.
Expand Down
33 changes: 33 additions & 0 deletions packages/daemon/src/sessions/handlers/__tests__/lease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,39 @@ test('lease_allocate answers with the lease id the request named', async () => {
assert.equal(registry.listActiveLeases()[0]?.leaseId, leaseId);
});

test('lease_allocate answers whether its request created the lease or got back an existing one', async () => {
const registry = new LeaseRegistry();
const leaseId = 'abcdef0123456789abcdef0123456789';
const allocate = (requestedLeaseId?: string) =>
handleLeaseCommands({
host: createTestHostEnvironment(),
req: {
token: 'test-token',
session: 'default',
command: 'lease_allocate',
positionals: [],
meta: {
tenantId: 'tenant-a',
runId: 'run-1',
...(requestedLeaseId ? { leaseId: requestedLeaseId } : {}),
},
},
sessionName: 'default',
sessionStore: makeSessionStore('agent-device-allocate-reused-'),
leaseRegistry: registry,
});

const created = await allocate(leaseId);
assert.ok(created?.ok);
assert.equal(created.data?.reused, false);
// A repeat for the same run binding gets that lease back, and the answer says this request did
// not create it — a client that might end the lease must end only the one its request created.
const repeated = await allocate();
assert.ok(repeated?.ok);
assert.equal((repeated.data?.lease as DeviceLease | undefined)?.leaseId, leaseId);
assert.equal(repeated.data?.reused, true);
});

test('lease_release still releases a retainOnClose lease through the provider', async () => {
const registry = new LeaseRegistry();
const lease = registry.allocateLease({
Expand Down
8 changes: 7 additions & 1 deletion packages/daemon/src/sessions/handlers/lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,13 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
const allocated = leaseRegistry.getLease(leaseReleaseRequestFor(lease)) ?? lease;
return {
ok: true,
data: { lease: allocated, ...(providerData ? { provider: providerData } : {}) },
data: {
lease: allocated,
// Whether this request was handed a lease that already existed. A caller that may end
// the lease needs this: the returned lease alone does not say who created it.
reused,
...(providerData ? { provider: providerData } : {}),
},
};
});
}
Expand Down
7 changes: 7 additions & 0 deletions packages/remote/src/remote-connection-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,20 @@ export function readRemoteConnectionState(options: {
export function writeRemoteConnectionState(options: {
Comment thread
thymikee marked this conversation as resolved.
Resolved
stateDir: string;
state: RemoteConnectionState;
/**
* Runs once the session record has landed, before the active-session pointer is updated. A caller
* that was holding the lease this record names until it was durable acts here: from this point the
* record owns it, and a later pointer failure must not be read as a record that never came to be.
*/
onSessionRecordCommitted?: () => void;
}): void {
const statePath = remoteConnectionStatePath({
stateDir: options.stateDir,
session: options.state.session,
});
fs.mkdirSync(path.dirname(statePath), { recursive: true });
writeJsonFile(statePath, options.state);
options.onSessionRecordCommitted?.();
Comment thread
thymikee marked this conversation as resolved.
Resolved
writeJsonFile(activeConnectionStatePath(options.stateDir), { session: options.state.session });
}

Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/daemon-client-lease-device-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@ test('a daemon refuses a lease allocate naming a device spec and records no leas
});
assert.equal(lease.leaseId, LEASE_ID);
assert.equal(lease.device, undefined);
// The answer that crosses the wire says this request created the lease, so a client that may
// end a lease can tell its own from one the daemon had already issued.
assert.equal(lease.created, true);
const repeated = await client.leases.allocate({
tenant: 'proxy',
runId: 'run-spec',
leaseBackend: 'ios-instance',
platform: 'ios',
});
assert.equal(repeated.leaseId, LEASE_ID);
assert.equal(repeated.created, false);
await client.leases.release({
tenant: 'proxy',
runId: 'run-spec',
leaseId: LEASE_ID,
leaseBackend: 'ios-instance',
});
} finally {
await daemon.close();
}
Expand Down
6 changes: 2 additions & 4 deletions src/__tests__/remote-connection-harmonyos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ import {
forceConnectFlags,
seedConnectionState,
} from './remote-connection.fixtures.ts';
import {
materializeRemoteConnectionForCommand,
resolveRequestedLeaseBackend,
} from '../cli/commands/connection-runtime.ts';
import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts';
import { resolveRequestedLeaseBackend } from '../cli/commands/connection-lease-transaction.ts';
import { disconnectCommand } from '../cli/commands/connection.ts';
import { readRemoteConnectionState } from '@agent-device/remote/remote-connection-state';

Expand Down
Loading
Loading