Skip to content

Commit 7490b82

Browse files
committed
fix(provider-webdriver): keep a lease owned through its whole disposal
Two windows stayed open on the reviewed head. An allocation validated its profile fields before it published the lease's entry, so a release issued while that check was pending found no entry, answered that the lease held nothing, and the create that resumed billed a session no route was left to stop. Cloud leases without a device key are not serialized by the daemon's lease registry, so both calls can be in flight at once. The entry is now published before the allocation's first await, and the publishing caller's own flags are refused by the create it stands on, as its first step, so a call that cannot be honoured still bills nothing. A caller that joined an existing entry is still refused for its own flags, and its live session answer is still read with no await after the lookup. Ending a session also waited on the plugin's artifact lookup, which has no bound of its own. A custom listArtifacts that never answered kept the lease DEVICE_IN_USE for every later caller and was awaited by daemon shutdown. Ownership now ends once the remote DELETE and the provider's cleanup settle; the artifact report is started at that point and joined only by the release that carries it. Both provider callbacks the manager awaits are bounded by providerCallbackTimeoutMs, defaulting to this package's one-round-trip budget, and a cleanup that outlives it is reported as PROVIDER_CLEANUP_FAILED with the session's outcome uncertain rather than holding the lease. Also: a disposal that could not be observed at all now reports itself instead of hanging every waiter; the reuse answer and heartbeat share one envelope builder; and a shutdown's hint names the shutdown rather than a release.
1 parent 8b2bdb0 commit 7490b82

3 files changed

Lines changed: 358 additions & 56 deletions

File tree

‎packages/provider-webdriver/src/runtime-session-allocation.test.ts‎

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,35 @@ test('an allocation refused for its own flags leaves the create it joined alone'
8585
}
8686
});
8787

88+
/**
89+
* A new allocation publishes its entry before it can await, and its own flags are the create's first
90+
* step, so a call this provider cannot honour still bills nothing and never reaches preparation.
91+
*/
92+
test('an allocation refused for its own flags bills nothing before it reaches the provider', async () => {
93+
const hub = startWebDriverHub();
94+
const preparation = startProviderPreparation();
95+
const runtime = makeRuntime({
96+
prepareSession: preparation.prepareSession,
97+
profileFields: refuseAllProfileFields('webdriver-test'),
98+
});
99+
const lease = makeLease();
100+
101+
try {
102+
globalThis.fetch = hub.fetch;
103+
assertRejectedWith(
104+
await settle(() =>
105+
runtime.leaseLifecycle.allocate!(lease, { flags: { providerApp: 'b' } } as never),
106+
),
107+
'INVALID_ARGS',
108+
{ flags: ['--provider-app'] },
109+
);
110+
assert.deepEqual(hub.created, [], 'a refused allocation never asks the hub to bill a device');
111+
assert.equal(preparation.started, 0, 'a refused allocation never reaches the provider');
112+
} finally {
113+
await runtime.shutdown();
114+
}
115+
});
116+
88117
/**
89118
* A release that landed mid-creation found no published session and answered that the lease held
90119
* nothing, so the hub session that creation went on to make had no route left to stop it. The caller
@@ -281,6 +310,162 @@ test('a repeated allocation cannot answer that a shut down lease is alive', asyn
281310
}
282311
});
283312

313+
/**
314+
* An allocation reaches its first await before provider preparation begins, and a release issued in
315+
* that moment used to find nothing holding the lease and answer that it had released it. The create that
316+
* went on to run then billed a session no route was left to stop. The lease has to belong to the
317+
* allocation from the moment it is asked for, so the release ends up taking it and disposing what the
318+
* create rents.
319+
*/
320+
test('a release issued before provider preparation begins disposes the session the allocation rents', async () => {
321+
const hub = startWebDriverHub();
322+
const preparation = startProviderPreparation();
323+
const runtime = makeRuntime({ prepareSession: preparation.prepareSession });
324+
const lease = makeLease();
325+
326+
try {
327+
globalThis.fetch = hub.fetch;
328+
// Both calls are issued in one synchronous step, so the release reaches the manager while the
329+
// allocation is at its first await and provider preparation has not answered.
330+
const allocating = settle(() => runtime.leaseLifecycle.allocate!(lease));
331+
const releasing = settle(() => runtime.leaseLifecycle.release!(lease));
332+
assert.deepEqual(hub.created, [], 'nothing may be billed before preparation answers');
333+
// The allocation has reached provider preparation, which is still held.
334+
await preparation.waitFor(1);
335+
await preparation.answerHeld();
336+
337+
assert.deepEqual(await releasing, {
338+
status: 'resolved',
339+
value: { provider: 'webdriver-test', providerSessionId: 'session-1' },
340+
});
341+
assertRejectedWith(await allocating, 'provider_session_released_during_allocation', {
342+
leaseId: lease.leaseId,
343+
providerSessionId: 'session-1',
344+
cleanup: 'confirmed',
345+
});
346+
// The session the create rented is gone: a release that reported success leaves no billed session.
347+
assert.deepEqual(hub.deleted, ['session-1']);
348+
} finally {
349+
await preparation.answerHeld();
350+
await runtime.shutdown();
351+
}
352+
});
353+
354+
/**
355+
* Ending a session ends the billed resource and hands back what the provider prepared; the artifact
356+
* lookup that follows is only what the release envelope reports. A plugin's own artifact callback has
357+
* no bound of its own, so it may not be what keeps a lease from being allocated again, what keeps a
358+
* second release from answering, or what a daemon close waits through.
359+
*/
360+
test('an artifact callback that never answers holds neither the lease nor shutdown', async () => {
361+
const hub = startWebDriverHub();
362+
globalThis.fetch = hub.fetch;
363+
let answerArtifacts: (() => void) | undefined;
364+
let lookupStarted = false;
365+
const runtime = makeRuntime({
366+
providerCallbackTimeoutMs: 40,
367+
listArtifacts: async () => {
368+
lookupStarted = true;
369+
await new Promise<void>((ready) => {
370+
answerArtifacts = ready;
371+
});
372+
return { provider: 'webdriver-test', status: 'ready', cloudArtifacts: [] };
373+
},
374+
});
375+
const lease = makeLease();
376+
377+
try {
378+
await runtime.leaseLifecycle.allocate!(lease);
379+
const releasing = settle(() => runtime.leaseLifecycle.release!(lease));
380+
// The lookup starts only once the hub has ended the session and the provider's cleanup has answered,
381+
// so its having started is the moment the billed work is finished.
382+
await untilSettled(() => lookupStarted);
383+
assert.deepEqual(hub.deleted, ['session-1']);
384+
await schedulerYield();
385+
386+
// A re-allocation during the hanging lookup is the lease's own business, not the lookup's.
387+
const reallocated = settle(() => runtime.leaseLifecycle.allocate!(lease));
388+
assertResolvedWith(await reallocated, 'session-2', describe(await reallocated));
389+
const shuttingDown = settle(() => runtime.shutdown());
390+
assert.deepEqual(
391+
await shuttingDown,
392+
{ status: 'resolved', value: undefined },
393+
'shutdown may not wait on an optional artifact lookup',
394+
);
395+
396+
const result = await releasing;
397+
assert.equal(result.status, 'resolved', describe(result));
398+
const value = (result as Readonly<{ value: Record<string, unknown> | undefined }>).value;
399+
assert.equal(value?.providerSessionId, 'session-1');
400+
// What the lookup could not answer for becomes an unavailable report, the way its failures do.
401+
assert.equal(
402+
(value?.cloudArtifacts as Record<string, unknown> | undefined)?.status,
403+
'unavailable',
404+
);
405+
assert.deepEqual(hub.created, ['session-1', 'session-2']);
406+
assert.deepEqual(hub.deleted, ['session-1', 'session-2']);
407+
} finally {
408+
answerArtifacts?.();
409+
await runtime.shutdown();
410+
}
411+
});
412+
413+
/**
414+
* A provider's own cleanup is the half of disposal the hub's `DELETE` does not cover, and a custom one
415+
* has no bound either. Once its budget runs out this manager cannot learn whether the resource was
416+
* handed back, so it says so and lets the lease be used again rather than holding it forever.
417+
*/
418+
test('a provider cleanup that outlives its budget ends the disposal as uncertain', async () => {
419+
const hub = startWebDriverHub();
420+
globalThis.fetch = hub.fetch;
421+
let answerCleanup: (() => void) | undefined;
422+
let cleanupCalls = 0;
423+
const runtime = makeRuntime({
424+
providerCallbackTimeoutMs: 40,
425+
prepareSession: async ({ base }) => ({
426+
...base,
427+
cleanup: async () => {
428+
cleanupCalls += 1;
429+
if (cleanupCalls > 1) return undefined;
430+
await new Promise<void>((ready) => {
431+
answerCleanup = ready;
432+
});
433+
return undefined;
434+
},
435+
}),
436+
});
437+
const lease = makeLease();
438+
439+
try {
440+
await runtime.leaseLifecycle.allocate!(lease);
441+
const releasing = settle(() => runtime.leaseLifecycle.release!(lease));
442+
await untilSettled(() => hub.deleted.includes('session-1'));
443+
444+
const result = await releasing;
445+
assert.equal(result.status, 'resolved', describe(result));
446+
const value = (result as Readonly<{ value: Record<string, unknown> | undefined }>).value;
447+
assert.deepEqual(warningCodes(value?.warnings), ['PROVIDER_CLEANUP_FAILED']);
448+
assert.match(String(warningMessages(value?.warnings)[0]), /40ms/);
449+
450+
// The lease is the next caller's again, and a later session this manager can end still ends cleanly.
451+
assertResolvedWith(
452+
await settle(() => runtime.leaseLifecycle.allocate!(lease)),
453+
'session-2',
454+
'a cleanup whose outcome is unknown may not keep the lease',
455+
);
456+
assert.deepEqual(
457+
await settle(() => runtime.shutdown()),
458+
{ status: 'resolved', value: undefined },
459+
'only a session the provider would not end may fail a close',
460+
);
461+
assert.deepEqual(hub.created, ['session-1', 'session-2']);
462+
assert.deepEqual(hub.deleted, ['session-1', 'session-2']);
463+
} finally {
464+
answerCleanup?.();
465+
await runtime.shutdown();
466+
}
467+
});
468+
284469
/**
285470
* A hub that will not end a session leaves it billing, so the release that was told to end it names the
286471
* session and the provider's refusal, exactly as it does for a session it was handed ready-made.
@@ -484,12 +669,30 @@ async function schedulerYield(): Promise<void> {
484669
});
485670
}
486671

672+
/**
673+
* Waits for the manager to reach `condition` — the hub having been asked to delete a session, say — so a
674+
* test can name the moment after the billed work is done and before an optional callback has answered.
675+
*/
676+
async function untilSettled(condition: () => boolean, withinMs = 2_000): Promise<void> {
677+
for (let waited = 0; !condition() && waited < withinMs; waited += 5) {
678+
await new Promise<void>((ready) => {
679+
setTimeout(ready, 5);
680+
});
681+
}
682+
assert.ok(condition(), 'the manager never reached the step the test was waiting for');
683+
}
684+
487685
/** The allocation envelope names the hub session the lease now holds. */
488686
function assertResolvedWith(
489687
outcome: Settled<Record<string, unknown> | undefined>,
490688
sessionId: string,
689+
expectation?: string,
491690
): void {
492-
assert.equal(outcome.status, 'resolved', `expected ${sessionId}, got ${describe(outcome)}`);
691+
assert.equal(
692+
outcome.status,
693+
'resolved',
694+
`${expectation ?? `expected ${sessionId}`}, got ${describe(outcome)}`,
695+
);
493696
assert.equal(
494697
(outcome as Readonly<{ value: Record<string, unknown> | undefined }>).value?.sessionId,
495698
sessionId,

0 commit comments

Comments
 (0)