Skip to content

Commit e6f2880

Browse files
authored
fix(daemon): let admitted work keep the lease it is working on (#2517)
* fix(daemon): let admitted work keep the lease it is working on A lease renewed only at admission, so a command slower than its own inactivity TTL expired the lease that was paying for the device it was using. Expiry then tore the provider session down underneath a client still waiting for that same command's result, and every later command on the session reported a lease that was no longer active. The session's own work was the thing that killed it. Admitted work now preserves its lease the way a human-control hold does: while the request is still wanted it defers expiry, and finishing while still wanted renews the lease for its existing TTL from the moment the work ended. Work whose client hung up preserves nothing — it neither defers expiry past that cancellation nor renews the lease when it finally lands — so a handler that ignores its cancellation cannot hold a rented device open. Found while investigating #2509. Not its reported mechanism: a cloud WebDriver connection profile asks for a ten-minute lease, so a one-minute hang cannot starve it. This reaches leases on the daemon's one-minute default. * fix(daemon): drop a released lease's work claims instead of leaving them empty A completed pass emptied its set but left the key behind, and only the expiry sweep removed keys — which never reads a released lease again. Every connect-and-close that ran a command on a leased device left another permanent entry in the daemon. Releasing the last pass now removes its lease's entry, and releasing the lease drops its claims outright and disarms the passes still running on them, so work that outlives its own lease renews nothing. * docs(daemon): state the lease invariant without borrowing #2509's cause Four comments told the report's story as though it were this mechanism. A cloud WebDriver connection profile asks for a ten-minute lease, so the reported one-minute hang cannot have expired anything. The invariant stands on its own; where it came from and which leases it reaches belong in ADR 0007 and the commit, not in each test.
1 parent b92b6ca commit e6f2880

9 files changed

Lines changed: 507 additions & 12 deletions

docs/adr/0007-remote-device-leases.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,22 @@ owning lease expiry.
5151
Backend-only leases remain valid for older remote clients, while provider-aware
5252
clients get device-level contention and clearer recovery.
5353

54+
## Admitted request work
55+
56+
A lease renews when a request is admitted and never again while that request runs, so an admitted
57+
command slower than the inactivity TTL used to expire the lease that was paying for its own device
58+
and tear the session down underneath the client still waiting for its result. Admitted work
59+
therefore preserves its lease the way a human-control hold does: while the request is still wanted
60+
it defers expiry, and finishing while still wanted renews the lease for its existing inactivity TTL
61+
from the moment the work ended. A request whose client hung up preserves nothing — it neither defers
62+
expiry past that cancellation nor renews the lease when it finally lands — so a handler that ignores
63+
its cancellation cannot hold a rented device open.
64+
65+
Which leases this reaches depends on the inactivity TTL the client asked for: the daemon default is
66+
one minute, while a cloud WebDriver connection profile asks for ten. A single command that runs
67+
longer than its own lease is therefore ordinary on the default and only reachable through a profile
68+
on the longer one.
69+
5470
## Human control
5571

5672
Human-control holds coexist with an open remote session. They belong to `LeaseRegistry` and use
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { test, expect } from 'vitest';
2+
import { LeaseInFlightWorkRegistry } from '../lease-in-flight-work.ts';
3+
4+
test('a pass defers its lease until it is released', () => {
5+
const work = new LeaseInFlightWorkRegistry();
6+
const pass = work.retain('lease-a', () => true);
7+
8+
expect(work.isDeferred('lease-a')).toBe(true);
9+
expect(pass.release()).toBe(true);
10+
expect(work.isDeferred('lease-a')).toBe(false);
11+
});
12+
13+
// The deferral is a claim that somebody is still waiting. The client hanging up
14+
// ends the claim immediately, without waiting for the abandoned work to unwind.
15+
test('a pass whose request was cancelled stops deferring unreleased', () => {
16+
const work = new LeaseInFlightWorkRegistry();
17+
let wanted = true;
18+
const pass = work.retain('lease-a', () => wanted);
19+
wanted = false;
20+
21+
expect(work.isDeferred('lease-a')).toBe(false);
22+
expect(pass.release()).toBe(false);
23+
});
24+
25+
// Two requests can work one leased device. Only work still wanted defers, and one
26+
// release must not disturb a pass that outlives it.
27+
test('one wanted pass keeps deferral while an abandoned sibling releases', () => {
28+
const work = new LeaseInFlightWorkRegistry();
29+
let abandonedWanted = true;
30+
const abandoned = work.retain('lease-a', () => abandonedWanted);
31+
const wanted = work.retain('lease-a', () => true);
32+
abandonedWanted = false;
33+
34+
expect(abandoned.release()).toBe(false);
35+
expect(work.isDeferred('lease-a')).toBe(true);
36+
expect(wanted.release()).toBe(true);
37+
expect(work.isDeferred('lease-a')).toBe(false);
38+
});
39+
40+
test('releasing a pass twice renews nothing twice', () => {
41+
const work = new LeaseInFlightWorkRegistry();
42+
const pass = work.retain('lease-a', () => true);
43+
44+
expect(pass.release()).toBe(true);
45+
expect(pass.release()).toBe(false);
46+
});
47+
48+
test('passes on different leases defer independently', () => {
49+
const work = new LeaseInFlightWorkRegistry();
50+
const other = work.retain('lease-b', () => true);
51+
52+
expect(work.isDeferred('lease-a')).toBe(false);
53+
expect(work.isDeferred('lease-b')).toBe(true);
54+
other.release();
55+
});
56+
57+
// Leases churn with every connect and close, and a released lease is never read by
58+
// the expiry sweep again. A key left behind per released lease is a permanent claim
59+
// on memory the daemon can never reclaim.
60+
test('releasing the last pass leaves no claim recorded for its lease', () => {
61+
const work = new LeaseInFlightWorkRegistry();
62+
const first = work.retain('lease-a', () => true);
63+
const second = work.retain('lease-b', () => true);
64+
65+
first.release();
66+
second.release();
67+
68+
expect(claimedLeaseIds(work)).toEqual([]);
69+
});
70+
71+
// Work that outlives its own lease renews nothing, and holds no key either.
72+
test('forgetting a lease releases its claims and disarms the passes running on it', () => {
73+
const work = new LeaseInFlightWorkRegistry();
74+
const pass = work.retain('lease-a', () => true);
75+
76+
work.forget('lease-a');
77+
78+
expect(claimedLeaseIds(work)).toEqual([]);
79+
expect(pass.release()).toBe(false);
80+
});
81+
82+
function claimedLeaseIds(work: LeaseInFlightWorkRegistry): string[] {
83+
const claims = (work as unknown as { entriesByLeaseId: Map<string, unknown> }).entriesByLeaseId;
84+
return [...claims.keys()];
85+
}

src/daemon/__tests__/lease-registry.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,3 +564,93 @@ test('canceling a superseded activation cannot remove its successor or another h
564564
);
565565
assert.equal(registry.listHumanControlHolds(authority)[0]?.reason, 'successor');
566566
});
567+
568+
// Nothing heartbeats a lease while its request works, so an admitted capture
569+
// that legitimately outruns the lease TTL expires its own lease and loses the
570+
// device it was still holding. In-flight work defers expiry and renews on
571+
// completion, exactly as a human-control hold does.
572+
test('in-flight request work defers expiry and renews the lease when it finishes', async () => {
573+
let now = 0;
574+
const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 5_000 });
575+
const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST);
576+
const pass = registry.retainLeaseWork(lease, () => true);
577+
578+
now = 9_000;
579+
assert.deepEqual(registry.consumeExpiredLeases(), []);
580+
assert.equal(registry.consumeExpiredLease(lease.leaseId), undefined);
581+
assert.equal(registry.listActiveLeases()[0]?.leaseId, lease.leaseId);
582+
583+
pass.release();
584+
assert.equal(registry.listActiveLeases()[0]?.expiresAt, 14_000);
585+
now = 14_000;
586+
assert.equal(registry.consumeExpiredLeases()[0]?.leaseId, lease.leaseId);
587+
});
588+
589+
// The deferral belongs to work somebody is still waiting for. A request whose
590+
// client hung up stops deferring the moment it is cancelled, and re-earns nothing
591+
// when it finally lands, so a handler that ignores cancellation cannot pin a
592+
// rented device open forever.
593+
test('a cancelled request stops deferring expiry and cannot revive its lease', async () => {
594+
let now = 0;
595+
const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 5_000 });
596+
const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST);
597+
let wanted = true;
598+
const pass = registry.retainLeaseWork(lease, () => wanted);
599+
600+
wanted = false;
601+
now = 9_000;
602+
assert.deepEqual(
603+
registry.consumeExpiredLeases().map((entry) => entry.leaseId),
604+
[lease.leaseId],
605+
);
606+
pass.release();
607+
assert.deepEqual(registry.listActiveLeases(), []);
608+
});
609+
610+
// The closest negative: two requests on one device, one abandoned. Releasing the
611+
// abandoned work must renew nothing while the wanted work still defers expiry.
612+
test('abandoned work renews nothing while work still wanted holds the lease', async () => {
613+
let now = 0;
614+
const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 5_000 });
615+
const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST);
616+
let abandonedWanted = true;
617+
const abandoned = registry.retainLeaseWork(lease, () => abandonedWanted);
618+
const wanted = registry.retainLeaseWork(lease, () => true);
619+
620+
abandonedWanted = false;
621+
now = 9_000;
622+
assert.equal(registry.listActiveLeases()[0]?.leaseId, lease.leaseId);
623+
624+
abandoned.release();
625+
assert.equal(
626+
registry.listActiveLeases()[0]?.expiresAt,
627+
5_000,
628+
'abandoned work must not renew the lease it was sitting on',
629+
);
630+
631+
wanted.release();
632+
assert.equal(registry.listActiveLeases()[0]?.expiresAt, 14_000);
633+
});
634+
635+
// The ordinary release path, not expiry: a released lease is never read by the
636+
// expiry sweep again, so a work claim left against it would never be cleaned.
637+
test('releasing a lease drops the work claims recorded against it', () => {
638+
const registry = new LeaseRegistry({ defaultLeaseTtlMs: 5_000 });
639+
const lease = registry.allocateLease({ tenantId: 'tenant-a', runId: 'run-9' });
640+
const pass = registry.retainLeaseWork(lease, () => true);
641+
642+
registry.releaseLease({ leaseId: lease.leaseId });
643+
pass.release();
644+
645+
assert.deepEqual(inFlightClaimKeys(registry), []);
646+
assert.equal(registry.listActiveLeases().length, 0, 'a released lease must not come back');
647+
});
648+
649+
function inFlightClaimKeys(registry: LeaseRegistry): string[] {
650+
const work = (
651+
registry as unknown as {
652+
inFlightWork: { entriesByLeaseId: Map<string, unknown> };
653+
}
654+
).inFlightWork;
655+
return [...work.entriesByLeaseId.keys()];
656+
}

src/daemon/__tests__/request-execution-scope.test.ts

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -509,8 +509,54 @@ test('expired leases remove owned sessions before the next command and free capa
509509
expect(nextLease.tenantId).toBe('tenant-b');
510510
});
511511

512+
// A lease renewed only at admission lets one command slower than its inactivity TTL
513+
// expire the lease paying for the device it is using, and expiry then tears the
514+
// provider session down under the client still waiting for that same command. Found
515+
// while investigating #2509, whose cloud session ran on a ten-minute lease and so
516+
// lost its session some other way.
517+
test('an admitted request that outlives the lease TTL keeps its lease and session', async () => {
518+
let now = 1_000;
519+
const sessionStore = makeSessionStore('agent-device-request-scope-');
520+
const leaseRegistry = new LeaseRegistry({
521+
defaultLeaseTtlMs: 10,
522+
minLeaseTtlMs: 1,
523+
now: () => now,
524+
});
525+
const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' });
526+
sessionStore.set(
527+
'default',
528+
makeIosSession('default', {
529+
lease: {
530+
leaseId: lease.leaseId,
531+
tenantId: lease.tenantId,
532+
runId: lease.runId,
533+
leaseBackend: lease.backend,
534+
expiresAt: lease.expiresAt,
535+
},
536+
}),
537+
);
538+
539+
const slow = await createRequestExecutionScope({
540+
req: makeRequest({ command: 'snapshot' }),
541+
sessionStore,
542+
leaseRegistry,
543+
});
544+
// The capture is still being waited on when it crosses the TTL, as a cloud
545+
// page-source read does on a screen that never goes idle.
546+
expect(await slow.runLocked(async () => (now = 1_011))).toBe(1_011);
547+
548+
const next = await createRequestExecutionScope({
549+
req: makeRequest({ command: 'screenshot' }),
550+
sessionStore,
551+
leaseRegistry,
552+
});
553+
expect(await next.runLocked(async () => 'ran')).toBe('ran');
554+
expect(sessionStore.get('default')).toBeDefined();
555+
});
556+
512557
test('expired leased session cleanup waits for the request execution lock', async () => {
513558
let now = 1_000;
559+
const requestId = 'request-scope-holds-execution-lock';
514560
const sessionStore = makeSessionStore('agent-device-request-scope-');
515561
const leaseRegistry = new LeaseRegistry({
516562
defaultLeaseTtlMs: 10,
@@ -531,7 +577,7 @@ test('expired leased session cleanup waits for the request execution lock', asyn
531577
}),
532578
);
533579
const first = await createRequestExecutionScope({
534-
req: makeRequest({ command: 'click' }),
580+
req: makeRequest({ command: 'click', meta: { requestId } }),
535581
sessionStore,
536582
leaseRegistry,
537583
});
@@ -554,16 +600,24 @@ test('expired leased session cleanup waits for the request execution lock', asyn
554600
}),
555601
);
556602
await firstEnteredPromise;
603+
// The client walked away from this request. Abandoned work no longer defers the
604+
// expiry it is sitting on, which is what makes this case about the lock and not
605+
// about in-flight lease liveness.
606+
markRequestCanceled(requestId);
557607

558608
now = 1_011;
559609
const secondRun = second.runLocked(async () => 'second');
560610
await new Promise((resolve) => setTimeout(resolve, 20));
561611
expect(sessionStore.get('default')).toBeDefined();
562612

563-
releaseFirst();
564-
await firstRun;
565-
await expect(secondRun).resolves.toBe('second');
566-
expect(sessionStore.get('default')).toBeUndefined();
613+
try {
614+
releaseFirst();
615+
await firstRun;
616+
await expect(secondRun).resolves.toBe('second');
617+
expect(sessionStore.get('default')).toBeUndefined();
618+
} finally {
619+
clearRequestCanceled(requestId);
620+
}
567621
});
568622

569623
test('tenant lease rejection flushes diagnostics into the effective session request log', async () => {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { test, expect } from 'vitest';
2+
import {
3+
clearRequestAbortRegistration,
4+
markRequestCanceled,
5+
registerRequestAbort,
6+
} from '@agent-device/host-kit/request';
7+
import type { DaemonRequest } from '../daemon-request.ts';
8+
import { LeaseRegistry } from '../lease-registry.ts';
9+
import { runAdmittedLeaseWork } from '../request-lease-work.ts';
10+
import { HUMAN_CONTROL_LEASE_REQUEST } from './human-control-fixtures.ts';
11+
12+
function admittedRequest(
13+
leaseRegistry: LeaseRegistry,
14+
overrides: Partial<DaemonRequest> = {},
15+
): DaemonRequest {
16+
return {
17+
token: 'test-token',
18+
session: 'default',
19+
command: 'snapshot',
20+
positionals: [],
21+
internal: { admittedLease: leaseRegistry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST) },
22+
...overrides,
23+
};
24+
}
25+
26+
// A capture that runs past the lease TTL used to expire the lease paying for the
27+
// device, so the session died underneath the client still waiting for it.
28+
test('admitted work that outlives the lease TTL keeps the lease it is working on', async () => {
29+
let now = 0;
30+
const leaseRegistry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 5_000 });
31+
const req = admittedRequest(leaseRegistry);
32+
33+
const worked = await runAdmittedLeaseWork({
34+
leaseRegistry,
35+
req,
36+
task: async () => {
37+
now = 9_000;
38+
expect(leaseRegistry.listActiveLeases()).toHaveLength(1);
39+
return 'captured';
40+
},
41+
});
42+
43+
expect(worked).toBe('captured');
44+
expect(leaseRegistry.listActiveLeases()[0]?.expiresAt).toBe(14_000);
45+
});
46+
47+
// The pass protects work somebody is still waiting for, and nothing else. Once the
48+
// client hangs up, work that lands later re-earns no lease: a handler that ignores
49+
// its cancellation cannot pin a rented device open.
50+
test('work nobody waited for renews nothing once its lease fell due', async () => {
51+
let now = 0;
52+
const leaseRegistry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 5_000 });
53+
const requestId = 'request-2509-abandoned';
54+
const registration = registerRequestAbort(requestId);
55+
try {
56+
const req = admittedRequest(leaseRegistry, { meta: { requestId } });
57+
const worked = await runAdmittedLeaseWork({
58+
leaseRegistry,
59+
req,
60+
task: async () => {
61+
now = 9_000;
62+
markRequestCanceled(requestId);
63+
return 'late';
64+
},
65+
});
66+
67+
expect(worked).toBe('late');
68+
expect(leaseRegistry.listActiveLeases()).toEqual([]);
69+
} finally {
70+
clearRequestAbortRegistration(registration);
71+
}
72+
});
73+
74+
test('a request admitted without a lease runs its work untouched', async () => {
75+
const leaseRegistry = new LeaseRegistry();
76+
const result = await runAdmittedLeaseWork({
77+
leaseRegistry,
78+
req: { token: 'test-token', session: 'default', command: 'status', positionals: [] },
79+
task: async () => 'ran',
80+
});
81+
82+
expect(result).toBe('ran');
83+
expect(leaseRegistry.listActiveLeases()).toEqual([]);
84+
});

0 commit comments

Comments
 (0)