Skip to content

Commit 6d43be2

Browse files
committed
fix: cancel pending human takeover on disconnect
1 parent 96fd2f9 commit 6d43be2

11 files changed

Lines changed: 302 additions & 62 deletions

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ their ownership checks. Unknown effects are treated as mutations. A pending acti
6868
mutations and drains those already admitted before reporting active; advisory execution locks alone
6969
do not establish this guarantee for fresh sessions.
7070

71+
Activation follows the calling RPC or host HTTP request's cancellation signal. A disconnect while
72+
draining removes only that request's pending hold, leaving successor and unrelated holds intact;
73+
canceling activation does not cancel the mutations being drained. Completed holds use their TTL or
74+
explicit release lifecycle.
75+
7176
Holds, like leases, are in-memory and do not survive daemon restart. Controllers must reconnect and
7277
re-establish them; no persisted hold store is used. Local takeover is deferred: a future host-global
7378
human-control fence must coexist with the local session's device claim, not acquire it exclusively.

src/daemon/__tests__/device-mutation-drain.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createControlLatch } from './human-control-fixtures.ts';
22
import assert from 'node:assert/strict';
33
import { test } from 'vitest';
44
import { DeviceMutationDrain } from '../device-mutation-drain.ts';
5+
import { getEventListeners } from 'node:events';
56

67
test('drain counts concurrent operations, releases on failure, and isolates device keys', async () => {
78
const drain = new DeviceMutationDrain();
@@ -24,3 +25,30 @@ test('drain counts concurrent operations, releases on failure, and isolates devi
2425
assert.equal(idle, true);
2526
await drain.wait('device-a');
2627
});
28+
29+
test('canceling a drain waiter detaches its signal without canceling mutations or other waiters', async () => {
30+
const drain = new DeviceMutationDrain();
31+
const finish = createControlLatch();
32+
const mutation = drain.run('device-a', () => finish.promise);
33+
const controller = new AbortController();
34+
const reason = new Error('canceled waiter');
35+
const rejected = assert.rejects(
36+
drain.wait('device-a', controller.signal),
37+
(error) => error === reason,
38+
);
39+
let drained = false;
40+
const survivor = drain.wait('device-a').then(() => {
41+
drained = true;
42+
});
43+
controller.abort(reason);
44+
await rejected;
45+
assert.equal(drained, false);
46+
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
47+
const successController = new AbortController();
48+
const successful = drain.wait('device-a', successController.signal);
49+
finish.resolve();
50+
await Promise.all([mutation, survivor, successful]);
51+
assert.equal(drained, true);
52+
assert.equal(getEventListeners(successController.signal, 'abort').length, 0);
53+
await assert.rejects(drain.wait('device-a', controller.signal), (error) => error === reason);
54+
});

src/daemon/__tests__/human-control-http.test.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import assert from 'node:assert/strict';
2-
import type http from 'node:http';
3-
import { test } from 'vitest';
2+
import http from 'node:http';
3+
import { test, vi } from 'vitest';
44
import {
55
closeLoopbackServer,
66
listenOnLoopback,
77
skipWhenLoopbackUnavailable,
88
} from '../../__tests__/test-utils/loopback.ts';
99
import { HUMAN_CONTROL_HTTP_PREFIX } from '../human-control-contract.ts';
1010
import { LeaseRegistry } from '../lease-registry.ts';
11-
import { HUMAN_CONTROL_SCOPE, humanControlRequest } from './human-control-fixtures.ts';
11+
import {
12+
HUMAN_CONTROL_SCOPE,
13+
humanControlRequest,
14+
createControlLatch,
15+
} from './human-control-fixtures.ts';
1216
import { createHumanControlHarness } from './human-control-router-fixture.ts';
1317
import { tryHandleHumanControlHttpRoute } from '../human-control-http.ts';
1418
import { createDaemonHttpServer } from '../server/http-server.ts';
@@ -47,6 +51,76 @@ test('malformed request URLs return a normalized error', async () => {
4751
assert.equal((JSON.parse(responseBody) as { code?: string }).code, 'INVALID_ARGS');
4852
});
4953

54+
for (const transport of ['tenant RPC', 'host PUT'] as const) {
55+
test(`${transport} disconnect during drain removes its pending hold before the mutation ends`, async (t) => {
56+
if (await skipWhenLoopbackUnavailable(t)) return;
57+
const { registry, lease, handleRequest } = createHumanControlHarness();
58+
const finish = createControlLatch();
59+
const disconnected = createControlLatch();
60+
let mutationFinished = false;
61+
const mutation = registry.runDeviceMutation(lease, async () => {
62+
await finish.promise;
63+
mutationFinished = true;
64+
});
65+
const server = await createDaemonHttpServer({
66+
token: 'test-token',
67+
leaseRegistry: registry,
68+
handleRequest,
69+
});
70+
server.on('request', (_req, res) => {
71+
res.once('close', () => {
72+
if (!res.writableFinished) disconnected.resolve();
73+
});
74+
});
75+
let request: http.ClientRequest | undefined;
76+
try {
77+
const port = await listenOnLoopback(server);
78+
const isRpc = transport === 'tenant RPC';
79+
const body = JSON.stringify(
80+
isRpc
81+
? {
82+
jsonrpc: '2.0',
83+
id: 'disconnected-takeover',
84+
method: 'agent_device.command',
85+
params: humanControlRequest(lease, 'human_control', ['put', 'disconnected', '{}']),
86+
}
87+
: { scope: HUMAN_CONTROL_SCOPE },
88+
);
89+
request = http.request({
90+
host: '127.0.0.1',
91+
port,
92+
path: isRpc ? '/rpc' : `${HUMAN_CONTROL_HTTP_PREFIX}/disconnected`,
93+
method: isRpc ? 'POST' : 'PUT',
94+
headers: {
95+
authorization: 'Bearer test-token',
96+
'content-type': 'application/json',
97+
'content-length': Buffer.byteLength(body),
98+
},
99+
});
100+
request.on('error', () => undefined);
101+
request.end(body);
102+
await vi.waitFor(() => {
103+
assert.equal(registry.listHumanControlHolds({ kind: 'host' })[0]?.state, 'activating');
104+
});
105+
request.destroy();
106+
await disconnected.promise;
107+
await vi.waitFor(() => {
108+
assert.deepEqual(registry.listHumanControlHolds({ kind: 'host' }), []);
109+
});
110+
assert.equal(mutationFinished, false);
111+
finish.resolve();
112+
await mutation;
113+
assert.deepEqual(registry.listHumanControlHolds({ kind: 'host' }), []);
114+
assert.equal(await registry.runDeviceMutation(lease, async () => 'resumed'), 'resumed');
115+
} finally {
116+
request?.destroy();
117+
finish.resolve();
118+
await mutation;
119+
await closeLoopbackServer(server);
120+
}
121+
});
122+
}
123+
50124
test('host administration and tenant RPC use the same lease registry with distinct authority', async (t) => {
51125
if (await skipWhenLoopbackUnavailable(t)) return;
52126
const { registry, lease, handleRequest } = createHumanControlHarness();

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'vitest';
3-
import { leaseDeviceBindingKey, normalizeAllocateLeaseRequest } from '../lease-registry-scope.ts';
3+
import {
4+
createLeaseTtlResolver,
5+
leaseDeviceBindingKey,
6+
normalizeAllocateLeaseRequest,
7+
} from '../lease-registry-scope.ts';
48
import { HUMAN_CONTROL_LEASE_REQUEST, HUMAN_CONTROL_SCOPE } from './human-control-fixtures.ts';
59

610
test('allocation and human control share the exact contention identity', () => {
@@ -20,3 +24,20 @@ test('allocation and human control share the exact contention identity', () => {
2024
);
2125
assert.equal(leaseDeviceBindingKey({ backend: 'ios-simulator' }), undefined);
2226
});
27+
28+
test('lease TTL normalization retains defaults, limits, and invalid configuration handling', () => {
29+
const defaults = createLeaseTtlResolver({});
30+
assert.equal(defaults(undefined), 60_000);
31+
assert.equal(defaults(1.5), 60_000);
32+
assert.equal(defaults(5_000), 5_000);
33+
assert.equal(defaults(600_000), 600_000);
34+
for (const ttl of [4_999, 600_001]) assert.throws(() => defaults(ttl), { code: 'INVALID_ARGS' });
35+
const configured = createLeaseTtlResolver({
36+
defaultLeaseTtlMs: 0,
37+
minLeaseTtlMs: 0,
38+
maxLeaseTtlMs: -1,
39+
});
40+
assert.equal(configured(undefined), 1);
41+
assert.equal(configured(1), 1);
42+
assert.throws(() => configured(2), { code: 'INVALID_ARGS' });
43+
});

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3+
import { createRequestCanceledError } from '@agent-device/kernel/errors';
34
import { LeaseRegistry } from '../lease-registry.ts';
45
import {
56
HUMAN_CONTROL_LEASE_REQUEST,
@@ -511,3 +512,55 @@ test('holds do not survive registry restart, including host holds created withou
511512
assert.deepEqual(restarted.listHumanControlHolds({ kind: 'host' }), []);
512513
assert.doesNotThrow(() => restarted.allocateLease(HUMAN_CONTROL_LEASE_REQUEST));
513514
});
515+
516+
test('canceled activation refreshes the protected lease without waiting for the mutation', async () => {
517+
let now = 0;
518+
const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 5_000 });
519+
const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST);
520+
const authority = { kind: 'lease', leaseId: lease.leaseId } as const;
521+
const finish = createControlLatch();
522+
const mutation = registry.runDeviceMutation(lease, () => finish.promise);
523+
const controller = new AbortController();
524+
const activation = registry.putHumanControlHold(authority, 'console', {}, controller.signal);
525+
const canceled = createRequestCanceledError();
526+
const rejected = assert.rejects(activation, (error) => error === canceled);
527+
now = 20_000;
528+
controller.abort(canceled);
529+
await rejected;
530+
assert.deepEqual(registry.listHumanControlHolds(authority), []);
531+
assert.equal(registry.listActiveLeases()[0]?.expiresAt, 25_000);
532+
finish.resolve();
533+
await mutation;
534+
assert.deepEqual(registry.listHumanControlHolds(authority), []);
535+
});
536+
537+
test('canceling a superseded activation cannot remove its successor or another hold', async () => {
538+
const registry = new LeaseRegistry();
539+
const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST);
540+
const authority = { kind: 'lease', leaseId: lease.leaseId } as const;
541+
const finish = createControlLatch();
542+
const mutation = registry.runDeviceMutation(lease, () => finish.promise);
543+
const controller = new AbortController();
544+
const canceled = createRequestCanceledError();
545+
const rejected = assert.rejects(
546+
registry.putHumanControlHold(authority, 'console', {}, controller.signal),
547+
(error) => error === canceled,
548+
);
549+
const successor = registry.putHumanControlHold(authority, 'console', { reason: 'successor' });
550+
const other = registry.putHumanControlHold(authority, 'other', {});
551+
controller.abort(canceled);
552+
await rejected;
553+
assert.deepEqual(
554+
registry.listHumanControlHolds(authority).map((hold) => hold.id),
555+
['console', 'other'],
556+
);
557+
finish.resolve();
558+
await mutation;
559+
assert.equal((await successor).reason, 'successor');
560+
assert.equal((await other).state, 'active');
561+
await assert.rejects(
562+
registry.putHumanControlHold(authority, 'console', {}, controller.signal),
563+
(error) => error === canceled,
564+
);
565+
assert.equal(registry.listHumanControlHolds(authority)[0]?.reason, 'successor');
566+
});

src/daemon/device-mutation-drain.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,24 @@ export class DeviceMutationDrain {
1919
}
2020
}
2121

22-
async wait(key: string): Promise<void> {
22+
async wait(key: string, signal?: AbortSignal): Promise<void> {
23+
signal?.throwIfAborted();
2324
if (!this.active.has(key)) return;
24-
await new Promise<void>((resolve) => {
25-
const waiters = this.waiters.get(key) ?? new Set<() => void>();
26-
waiters.add(resolve);
27-
this.waiters.set(key, waiters);
28-
});
25+
const waiters = this.waiters.get(key) ?? new Set<() => void>();
26+
let drained: () => void = () => {};
27+
let aborted: () => void = () => {};
28+
try {
29+
await new Promise<void>((resolve, reject) => {
30+
drained = resolve;
31+
aborted = () => reject(signal?.reason);
32+
waiters.add(drained);
33+
this.waiters.set(key, waiters);
34+
signal?.addEventListener('abort', aborted, { once: true });
35+
});
36+
} finally {
37+
signal?.removeEventListener('abort', aborted);
38+
waiters.delete(drained);
39+
if (waiters.size === 0 && this.waiters.get(key) === waiters) this.waiters.delete(key);
40+
}
2941
}
3042
}

src/daemon/handlers/human-control.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AppError } from '@agent-device/kernel/errors';
2+
import { getRequestSignal } from '@agent-device/host-kit/request';
23
import { parseHumanControlHoldInput } from '../human-control-contract.ts';
34
import type { LeaseRegistry } from '../lease-registry.ts';
45
import type { DaemonRequest, DaemonResponse } from '../types.ts';
@@ -21,7 +22,12 @@ export async function handleHumanControlCommand(params: {
2122
return { ok: true, data: { holds: registry.listHumanControlHolds(authority) } };
2223
case 'put': {
2324
assertArgumentCount(positionals, 3);
24-
const hold = await registry.putHumanControlHold(authority, holdId, readHoldInput(rawInput));
25+
const hold = await registry.putHumanControlHold(
26+
authority,
27+
holdId,
28+
readHoldInput(rawInput),
29+
getRequestSignal(req.meta?.requestId),
30+
);
2531
return { ok: true, data: { hold, state: 'active' } };
2632
}
2733
case 'remove': {

src/daemon/human-control-http.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type http from 'node:http';
2-
import { AppError, normalizeError } from '@agent-device/kernel/errors';
2+
import { AppError, createRequestCanceledError, normalizeError } from '@agent-device/kernel/errors';
33
import { readNodeHttpRequestBody } from '../utils/node-http.ts';
44
import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts';
55
import { sendRestJsonError } from './http-errors.ts';
@@ -75,9 +75,27 @@ async function upsertHumanControlHold(
7575
holdId: string,
7676
params: HumanControlHttpParams,
7777
): Promise<void> {
78-
const input = await readHoldInput(params.req);
79-
const hold = await params.registry.putHumanControlHold({ kind: 'host' }, holdId, input);
80-
sendJson(params.res, { ok: true, hold, state: 'active' });
78+
const { req, res } = params;
79+
const controller = new AbortController();
80+
const cancelIfDisconnected = () => {
81+
if (!res.writableFinished) controller.abort(createRequestCanceledError());
82+
};
83+
req.once('aborted', cancelIfDisconnected);
84+
res.once('close', cancelIfDisconnected);
85+
if (req.aborted || res.destroyed) cancelIfDisconnected();
86+
try {
87+
const input = await readHoldInput(req);
88+
const hold = await params.registry.putHumanControlHold(
89+
{ kind: 'host' },
90+
holdId,
91+
input,
92+
controller.signal,
93+
);
94+
sendJson(res, { ok: true, hold, state: 'active' });
95+
} finally {
96+
req.off('aborted', cancelIfDisconnected);
97+
res.off('close', cancelIfDisconnected);
98+
}
8199
}
82100

83101
function removeHumanControlHold(holdId: string, params: HumanControlHttpParams): void {

src/daemon/lease-registry-scope.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,31 @@ export type NormalizedAllocateLeaseRequest = {
8383
ttlMs?: number;
8484
};
8585

86-
export const DEFAULT_LEASE_TTL_MS = 60_000;
87-
export const MIN_LEASE_TTL_MS = 5_000;
88-
export const MAX_LEASE_TTL_MS = 10 * 60_000;
86+
const DEFAULT_LEASE_TTL_MS = 60_000;
87+
const MIN_LEASE_TTL_MS = 5_000;
88+
const MAX_LEASE_TTL_MS = 10 * 60_000;
8989
const DEFAULT_LEASE_PROVIDER = 'default';
9090

91+
export function createLeaseTtlResolver(options: LeaseRegistryOptions) {
92+
const defaultTtl = Number.isInteger(options.defaultLeaseTtlMs)
93+
? Math.max(1, Number(options.defaultLeaseTtlMs))
94+
: DEFAULT_LEASE_TTL_MS;
95+
const minTtl = Number.isInteger(options.minLeaseTtlMs)
96+
? Math.max(1, Number(options.minLeaseTtlMs))
97+
: MIN_LEASE_TTL_MS;
98+
const maxTtl = Number.isInteger(options.maxLeaseTtlMs)
99+
? Math.max(minTtl, Number(options.maxLeaseTtlMs))
100+
: MAX_LEASE_TTL_MS;
101+
return (raw: number | undefined): number => {
102+
if (!Number.isInteger(raw)) return defaultTtl;
103+
const value = Number(raw);
104+
if (value < minTtl || value > maxTtl) {
105+
throw new AppError('INVALID_ARGS', `Lease ttlMs must be between ${minTtl} and ${maxTtl}.`);
106+
}
107+
return value;
108+
};
109+
}
110+
91111
function normalizeRunId(raw: string | undefined): string | undefined {
92112
if (!raw) return undefined;
93113
const value = raw.trim();

0 commit comments

Comments
 (0)