fix(usage): close the remaining withdrawal races, reset per-item name consent
Follow-up review on the previous commit, including a hole in that commit's own fix. The cancellation flag became a counter. Clearing a boolean needed a write of its own, and a /disable landing between the lease and that write was erased — the same race one level down. enable() now records the counter it started with and claims only if it is unchanged, so no clearing write exists to lose. It also fixes the case a boolean could not express at all: a stale cancellation already set, and a fresh one arriving mid-activation, are indistinguishable as flags and obvious as counts. Migration 203, separate from 202 for the reason 202 was separate from 201 — knex will not re-run an applied migration. deliver() re-checks immediately before dispatch. The existing check ran before the binding lookup, which is asynchronous, so a withdrawal that COMPLETED during it still had its registration or report sent afterwards. Not an already-in-flight request — a new one started after the operator had withdrawn. The outbox writes in tick() and command() are conditional on still being active. /disable clears pending_packet without holding the lease, so an unconditional write put a report — or a feedback body and name — back into an outbox the withdrawal had just emptied, where deliver() would then leave it, since it declines to send anything but the delete. Per-item name consent resets with the item. `named` stayed checked after submitting, so the next item carried the previous name automatically, contradicting the anonymous-by-default promise the disclosure makes for each item. The remembered name stays in preferences; attaching it is decided again each time. Two of these tests were worthless when first written and are noted because the pattern keeps recurring: the pre-dispatch case passed without the guard because an empty report payload failed schema validation during signing, so nothing reached the collector for reasons unrelated to the check. With a valid payload it fails without the guard and passes with it. Same for the counter: dropping it from the claim fails two. Refs #1110
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
// Supersedes the boolean added in 202. A boolean cannot distinguish "a
|
||||
// withdrawal arrived while this activation was starting" from "a withdrawal
|
||||
// from an earlier participation was never cleared": clearing it needed its
|
||||
// own write, and a /disable landing between the lease and that write was
|
||||
// erased. A monotonic counter needs no clearing — enable() records the value
|
||||
// it started with and claims only if it is unchanged, so any intervening
|
||||
// withdrawal is visible whatever the previous state was.
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
if (!(await knex.schema.hasColumn('product_usage_state', 'cancel_seq')))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
|
||||
});
|
||||
if (await knex.schema.hasColumn('product_usage_state', 'cancel_requested'))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.dropColumn('cancel_requested');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
if (await knex.schema.hasColumn('product_usage_state', 'cancel_seq'))
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.dropColumn('cancel_seq');
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user