fix(usage): take the withdrawal baseline before the lease, not after it

Third and last window in the same race, and again in my own fix.

locked() claims the lease and reads the row in two separate statements.
Reading the cancellation counter from inside that callback meant a
/disable completing in the gap was adopted as this activation's own
baseline and silently absorbed — the counter matched, the claim
succeeded, and registration went ahead after the operator had withdrawn.

The baseline is now read before the lease is taken, which inverts it:
every increment from that point on is later than the value the claim
tests for, so the claim fails and the withdrawal wins. An increment from
before the read is a withdrawal the operator already completed, and a
deliberate opt-in afterwards should not be vetoed by it.

The test for this passed against the bug on its first two attempts. It
stubbed the state read to increment the counter AFTER reading the row,
so both the broken and the fixed version saw the old value and behaved
identically. The withdrawal has to land before the read returns for the
row to carry it — which is the whole point of the window. It now fails
without the fix.

Refs #1110
This commit is contained in:
Paul Nothaft
2026-09-05 22:17:44 +02:00
parent 22da018e1b
commit 9785b636a9
2 changed files with 41 additions and 8 deletions
@@ -188,4 +188,35 @@ describe('withdrawal during an in-flight activation', () => {
expect(posted).not.toContain('report');
});
it('honours a withdrawal that lands between the lease claim and the state read', async () => {
// locked() claims the lease and reads the row in two statements. A
// /disable completing in that gap used to be adopted as this
// activation's own baseline and absorbed, so registration went ahead
// after the operator had withdrawn.
db = await bootDb();
const service = makeService(db);
const realState = service.state.bind(service);
let fired = false;
service.state = async () => {
// The withdrawal must land BEFORE this read returns, so the row carries
// the incremented counter. Incrementing afterwards would hand back the
// old value and both the broken and fixed versions would behave the
// same — which is exactly how an earlier version of this test passed
// against the bug it was meant to catch.
const first = await realState();
if (!fired && first.lease_token) {
fired = true;
await db('product_usage_state').where({ id: 1 }).increment('cancel_seq', 1);
return realState();
}
return first;
};
await service.enable('usage-consent.v1');
const row = await db('product_usage_state').where({ id: 1 }).first();
expect(row.status).toBe('disabled');
expect(row.installation_id).toBeNull();
});
});