Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
004767e
fix(lease): renew a heartbeat with no ttl for the window the lease al…
thymikee Sep 25, 2026
84abb66
fix(remote): renew the lease while an artifact upload runs on the cal…
thymikee Sep 25, 2026
f52305c
fix(lease): renew an admitted request for the window its lease carries
thymikee Sep 26, 2026
1c149b6
fix(remote): beat to the lease's own window and stop the upload with it
thymikee Sep 26, 2026
f3253cc
chore(gates): ack the upload wire digests the abort signal moved
thymikee Sep 26, 2026
ad1355e
fix(remote): treat a beat the daemon rejects on its own terms as term…
thymikee Sep 26, 2026
0f12bf2
test(remote): drop the connection-close wait that hung the cancellati…
thymikee Sep 26, 2026
4e4a389
fix(remote): reject an aborted upload with the reason it was aborted
thymikee Sep 26, 2026
e2a6dcf
chore(gates): ack the two upload digests the abort-reason fix moved
thymikee Sep 26, 2026
c4dc005
fix(remote): give each lease beat a budget and prove the beat on the …
thymikee Sep 26, 2026
4f739a4
refactor(remote): move the lease beat to its own module
thymikee Sep 26, 2026
ffabb5e
chore(gates): record the lease-beat module's type-only read of the wi…
thymikee Sep 26, 2026
612909b
docs(leases): state what the first beat actually buys, and what an ab…
thymikee Sep 26, 2026
d9b7af7
test(remote): let the caller release the stalled upload, so the outco…
thymikee Sep 26, 2026
8beb7fb
refactor(remote): fold the beat's thin wrappers into the one factory …
thymikee Sep 27, 2026
b391a4a
fix(remote): budget each beat from the window it protects, not the ca…
thymikee Sep 27, 2026
4c74a43
fix(remote): load the lease beat only for the request that can use it
thymikee Sep 27, 2026
0c9cc84
test(remote): give the slow-beat scenario the lane's parallel-scenari…
thymikee Sep 27, 2026
58a6110
test(remote): center the slow-beat number and un-detach the window doc
thymikee Sep 27, 2026
9ab78fb
test(remote): state the beat's transport cap, and fail the slow case …
thymikee Sep 27, 2026
c10e3a8
test(remote): settle the slow-beat count on arrivals instead of a wal…
thymikee Sep 27, 2026
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
50 changes: 50 additions & 0 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,56 @@ one minute, while a cloud WebDriver connection profile asks for ten. A single co
longer than its own lease is therefore ordinary on the default and only reachable through a profile
on the longer one.

## Client-side work that precedes admission

Protecting admitted work covers nothing that happens before a request is admitted. Installing an
artifact uploads it from the caller while the request that will consume it has not been admitted yet,
so an upload slower than the lease's inactivity window expired the lease paying for the device the
bytes were going to (#2946). The caller therefore beats the lease over the ordinary transport for as
long as that phase runs, naming the lease scope exactly as the command named it and no payload of its
own. An install names no window, so its beats renew for the window the lease already carries; a
caller that did name one keeps renewing on it. Resolving an absent window to the registry default
instead — which is what a heartbeat used to do — quietly shortened every lease allocated above that
default, which is the other half of why the upload could not survive. Request admission had its own
copy of that mistake: it named a proxy-specific default for every admitted request, so a lease
allocated longer than the default lost its window on the next command. Admission renews on the lease's
window too; the window a lease carries is the one its client named when it allocated.

The first beat is fired when the phase starts, not one cadence in, because a beat is what proves the
lease the upload is spending time on is still alive — a lease shorter than any fixed cadence would
otherwise lapse before anything renewed it. A lease already gone is caught early, but not before the
phase begins: hashing, the preflight, and the start of the stream can all run while the first beat is
still outstanding, so what the first beat buys is that the loss is learned during the upload rather
than after it. Each beat answers with the window it just renewed, and the loop arms its successor when
the beat starts rather than when it settles, so a beat that never answers is abandoned on schedule
instead of taking the schedule with it. A phase that settles does not wait on a beat still in flight.

Cadence and budget are deliberately two numbers. A beat is armed every third of the window, but it is
allowed the whole window to answer in. Budgeting a beat at its cadence instead would give a heartbeat
that needs more than a third of a window for its round trip — a tunneled proxy, an uplink the upload
itself is saturating — no way to answer at all, and every beat would re-time-out on exactly the slow
links the beat exists to protect. The window, not the cadence, is the interval in which being alive
still matters, so it is also the longest a beat's answer is worth waiting for.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

That ceiling is the shorter of the window and the heartbeat's own request policy, currently ninety
seconds. A ten-minute lease therefore gives a beat ninety seconds rather than the full window, which
costs nothing the beat exists to buy: the daemon renews the lease when it handles the request, before
it answers, so a round trip abandoned at the cap has still extended the lease if the request ever
landed — and if it never landed, a transport that has stopped carrying traffic is not served by holding
the socket open for the rest of a window measured in minutes. What the cap refuses is exactly that
idle hold. It binds only on windows longer than the policy, and on those the beat already has the
longest answer deadline any lease gets; shorter windows are bounded by their own window, as above.

A beat is a fresh request each time, never the protected request rewritten: a request identity is
what a timed-out beat is canceled under, and beats must not inherit each other's cancellation. A beat
that finds the lease gone, or finds that this request can never renew it — its scope is missing or
belongs to another lease, or the daemon rejects the scope and window the beat itself was built with,
which no successor will ask differently — ends the phase with that error and cancels the upload rather
than finishing bytes against a device nobody owns or a lease that will stop renewing. A beat that fails
for any other reason is reported and survived, because a later beat covers one lost request — including
one that was abandoned at its budget rather than answered, which is what makes that promise hold for a
stalled round trip and not only for one that fails fast.

## Human control

Human-control holds coexist with an open remote session. They belong to `LeaseRegistry` and use
Expand Down
27 changes: 27 additions & 0 deletions packages/contracts/src/__tests__/lease-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { test } from 'vitest';
import {
findMissingProxyLeaseFields,
isInactiveLeaseError,
leaseScopeFromOptions,
leaseScopeFromRequest,
leaseScopeToCommandFlags,
Expand Down Expand Up @@ -215,3 +216,29 @@ test('readLeaseAllocateProviderFlags carries the provider-allocation flags and d
);
assert.deepEqual(readLeaseAllocateProviderFlags(undefined), {});
});

test('isInactiveLeaseError recognizes the lease being gone, and refuses a request mismatch', () => {
for (const reason of ['LEASE_NOT_FOUND', 'LEASE_EXPIRED', 'LEASE_REVOKED']) {
assert.equal(
isInactiveLeaseError({ code: 'UNAUTHORIZED', details: { reason } }),
true,
`${reason} says the lease is no longer usable`,
);
}
// A mismatch is about the request that asked, not the lease: the lease may be perfectly alive and
// held by this same client under another request, so a beat must keep renewing on this answer.
assert.equal(
isInactiveLeaseError({ code: 'UNAUTHORIZED', details: { reason: 'LEASE_SCOPE_MISMATCH' } }),
false,
'a scope mismatch is not the lease being gone',
);
// Both halves are required: the code alone is any unauthorized call, and the reason alone could
// ride on an error the client has no business acting on.
assert.equal(isInactiveLeaseError({ code: 'UNAUTHORIZED', details: {} }), false);
assert.equal(
isInactiveLeaseError({ code: 'COMMAND_FAILED', details: { reason: 'LEASE_NOT_FOUND' } }),
false,
);
assert.equal(isInactiveLeaseError(new Error('LEASE_NOT_FOUND')), false);
assert.equal(isInactiveLeaseError(undefined), false);
});
36 changes: 35 additions & 1 deletion packages/contracts/src/lease-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { CloudProviderProfileFields } from './remote-config-fields.ts';
import type { CommandFlags } from './command-flags.ts';

const PROXY_LEASE_PROVIDER = 'proxy';
export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000;

const REQUIRED_PROXY_LEASE_FIELDS = [
'leaseId',
Expand Down Expand Up @@ -296,6 +295,41 @@ export function findMissingProxyLeaseFields(scope: LeaseScope): string[] {
return REQUIRED_PROXY_LEASE_FIELDS.filter((field) => !scope[field]);
}

/**
* The shortest inactivity window a lease can hold by default.
*
* The registry clamps every ttl to this floor, so it is also the worst case a client plans against
* before it has seen a lease answer: work that has to finish inside a lease window it does not know
* yet must assume this one, or it can be planning for a window the daemon would never grant.
*/
export const MIN_LEASE_WINDOW_MS = 5_000;

/**
* Why a lease stopped being ours: it is gone, spent, or taken back.
*
* This is the whole taxonomy of "the lease is no longer usable" as the daemon reports it, and both
* readers ask the same question — a client deciding whether a connection still owns a device, and a
* lease beat deciding whether an upload is still worth finishing. A reason naming a mismatch between
* a request and a lease is not in here: that says something about the request, not the lease.
*/
const INACTIVE_LEASE_REASONS: ReadonlySet<unknown> = new Set([
'LEASE_NOT_FOUND',
'LEASE_EXPIRED',
'LEASE_REVOKED',
]);

/** Whether `error` says the lease is gone rather than merely unavailable to this request. */
export function isInactiveLeaseError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
(error as Readonly<{ code?: unknown }>).code === 'UNAUTHORIZED' &&
INACTIVE_LEASE_REASONS.has(
(error as Readonly<{ details?: Readonly<{ reason?: unknown }> }>).details?.reason,
)
);
}

function leaseScopeToScopedRequest(scope: LeaseScope): LeaseScopedRequestScope {
return stripUndefined({
leaseId: scope.leaseId ?? '',
Expand Down
5 changes: 5 additions & 0 deletions scripts/layering/daemon-client-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export const DAEMON_CLIENT_ENTRY_EDGES: readonly DaemonClientEntryEdge[] = [
target: 'src/daemon/daemon-request.ts',
rationale: REQUEST_RATIONALE,
},
{
file: 'src/daemon-client/daemon-client-lease-beat.ts',
target: 'src/daemon/daemon-request.ts',
rationale: REQUEST_RATIONALE,
},
{
file: 'src/daemon-client/daemon-client-progress.ts',
target: 'src/daemon/daemon-request.ts',
Expand Down
199 changes: 199 additions & 0 deletions src/__tests__/upload-client-cancellation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Ending an artifact upload when the work it serves is over (#2946).
//
// A beat that finds the device's lease gone aborts the upload protecting that lease. An upload is a
// piped `node:http` request, so the only way to stop bytes already in flight is the request's own
// abort signal — a rejection the caller swallows would keep streaming a full app bundle to a device
// nobody owns. Kept out of `upload-client.test.ts`, which is already over the test-file size
// tripwire and may not grow (docs/agents/testing.md).

import { afterEach, test } from 'vitest';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import path from 'node:path';
import { once } from 'node:events';
import { uploadArtifact } from '../remote/upload-client.ts';
import { mkdtempForTestSync } from './test-utils/tmp-dir.ts';

const TEST_TOKEN = 'agent-device-upload-cancel-token';
const tempDirs: string[] = [];

afterEach(async () => {
for (const dir of tempDirs) {
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
}
tempDirs.length = 0;
});

test('an aborted signal ends a legacy upload mid-stream instead of finishing the bytes', async () => {
// Two megabytes is well past what a paused read lets through: the assertions below are about the
// stream stopping, and a smaller payload keeps that off the CPU in a loaded lane.
const content = Buffer.alloc(2 * 1024 * 1024, 'x');
const artifactPath = createTempFile('app.apk', content);
const control = new AbortController();
let sawBytes = 0;

// Preflight reports itself unsupported so the upload takes the legacy stream, the one path that
// pipes a file at the daemon and keeps going for as long as the daemon drains it.
const server = await startServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/upload/preflight') {
await readRequestBody(req);
res.statusCode = 404;
res.end('not found');
return;
}
if (req.method === 'POST' && req.url === '/upload') {
req.on('data', (chunk: Buffer) => {
sawBytes += chunk.length;
// The lease dies once bytes are genuinely in flight — not before the request starts.
// Stopping the read applies backpressure so the rest of the file cannot race through
// loopback and make "stopped early" a matter of timing.
req.pause();
if (!control.signal.aborted) control.abort();
});
return;
}
res.statusCode = 404;
res.end('not found');
});

try {
await assert.rejects(
async () =>
await uploadArtifact({
localPath: artifactPath,
baseUrl: server.baseUrl,
token: TEST_TOKEN,
signal: control.signal,
}),
isAbortError,
);
Comment thread
thymikee marked this conversation as resolved.
assert.ok(sawBytes > 0, 'the upload had started streaming before the abort');
assert.ok(sawBytes < content.length, `the stream stopped early, at ${sawBytes} bytes`);
} finally {
await server.close();
}
});

test('an aborted signal before preflight refuses to ask the daemon for a ticket', async () => {
const artifactPath = createTempFile('app.apk', 'payload');
const control = new AbortController();
control.abort();
const requests: string[] = [];

const server = await startServer(async (req, res) => {
requests.push(`${req.method} ${req.url}`);
res.statusCode = 404;
res.end('not found');
});

try {
await assert.rejects(
async () =>
await uploadArtifact({
localPath: artifactPath,
baseUrl: server.baseUrl,
token: TEST_TOKEN,
signal: control.signal,
}),
isAbortError,
);
assert.deepEqual(requests, [], 'an upload nobody waits for never reaches the daemon');
} finally {
await server.close();
}
});

test('an upload with no signal behaves exactly as before', async () => {
const content = 'unprotected-payload';
const artifactPath = createTempFile('app.apk', content);
const expectedHash = createHash('sha256').update(content).digest('hex');

const server = await startServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/upload/preflight') {
await readRequestBody(req);
res.statusCode = 404;
res.end('not found');
return;
}
if (req.method === 'POST' && req.url === '/upload') {
assert.equal(req.headers['x-artifact-hash'], expectedHash);
await readRequestBody(req);
sendJson(res, { ok: true, uploadId: 'upload-uncancelled' });
return;
}
res.statusCode = 404;
res.end('not found');
});

try {
const uploadId = await uploadArtifact({
localPath: artifactPath,
baseUrl: server.baseUrl,
token: TEST_TOKEN,
});
assert.equal(uploadId, 'upload-uncancelled');
} finally {
await server.close();
}
});

function isAbortError(error: unknown): boolean {
// The contract on `signal` is that an aborted upload rejects with the signal's own reason. Any
// other rejection — a transport failure, the server-error fallback — means the upload stopped for
// a reason this test is not about and would let the abort path regress while staying green.
return error instanceof DOMException && error.name === 'AbortError';
}

function createTempFile(filename: string, content: string | Buffer): string {
const dir = mkdtempForTestSync('agent-device-upload-cancel-');
tempDirs.push(dir);
const filePath = path.join(dir, filename);
fs.writeFileSync(filePath, content);
return filePath;
}

async function startServer(
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>,
): Promise<{ baseUrl: string; close: () => Promise<void> }> {
const server = http.createServer((req, res) => {
void handler(req, res).catch(() => {
res.statusCode = 500;
res.end();
});
});
// A canceled upload leaves its socket in a state `closeAllConnections` can race; without a bound
// idle keep-alive, `close` would then wait out Node's five-second default.
server.keepAliveTimeout = 100;
server.listen(0, '127.0.0.1');
server.unref();
await once(server, 'listening');
const address = server.address();
assert.ok(address && typeof address === 'object');
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () => {
// A canceled upload leaves its socket half-open and a pooled keep-alive one behind; without
// dropping them, `close` would wait out the server's five-second keep-alive timeout.
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}

async function readRequestBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}

function sendJson(res: ServerResponse, body: unknown): void {
res.statusCode = 200;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(body));
}
10 changes: 1 addition & 9 deletions src/cli/commands/connection-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type DeviceInfo,
} from '@agent-device/kernel/device';
import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts';
import { isInactiveLeaseError } from '@agent-device/contracts/lease-scope';
import {
buildRemoteConnectionDaemonState,
buildRemoteConnectionRequestMetadata,
Expand Down Expand Up @@ -991,12 +992,3 @@ async function heartbeatOrAllocateLease(
throw error;
}
}

function isInactiveLeaseError(error: unknown): boolean {
if (!(error instanceof AppError) || error.code !== 'UNAUTHORIZED') return false;
return (
error.details?.reason === 'LEASE_NOT_FOUND' ||
error.details?.reason === 'LEASE_EXPIRED' ||
error.details?.reason === 'LEASE_REVOKED'
);
}
Loading
Loading