Problem
run(), runVariableSql(), and runScript() guard re-entry by reading state.running.value, but they do not claim the execution slot until after awaiting authentication/configuration.
For example, run() currently follows this sequence:
if (state.running.value) return;
// prepare source
await deps.ensureConfig();
if (!(await deps.getToken())) { hooks.onAuthFailed(); return; }
// initialise shared bookkeeping
state.running.value = true;
runScript() and the dashboard-variable execution path use the same ordering.
If two invocations enter while ensureConfig() or getToken() is pending, both observe running === false and both proceed to execution after authentication completes.
This is realistic on the first run of a session, during a genuine token refresh, or through programmatic calls made before the first invocation reaches the running write.
Impact
The Workbench session stores active-run bookkeeping in four shared closure slots:
runT0
runQueryId
runTick
abortController
A second admitted invocation overwrites all four values. The two independent finally blocks then clear whichever operation currently owns the shared slots rather than the resources belonging to that invocation.
Concrete consequences include:
- the first interval handle can become unreachable and continue firing until page unload;
- the first completion can set
state.running.value = false while the second request is still streaming;
- the Run button and keyboard entry point re-enable, permitting further overlapping executions;
cancel() becomes a no-op after the premature running = false, or cancels only the most recently published controller/query ID;
- result and elapsed-time bookkeeping can be corrupted;
- two admitted
runScript() calls can execute the same INSERT, DDL, or other side-effecting statements twice.
The existing guard test only covers a call made when running was already true. It does not defer authentication and launch competing calls.
Suggested fix
Claim the execution slot synchronously before the first await, and represent lifecycle state with a per-invocation object rather than shared scalar fields.
For example:
interface ActiveRun {
controller: AbortController;
queryId: string | null;
tick: ReturnType<typeof setInterval> | null;
startedAt: number | null;
cancelled: boolean;
}
let activeRun: ActiveRun | null = null;
At each execution entry point:
if (activeRun) return;
const operation: ActiveRun = {
controller: new AbortController(),
queryId: null,
tick: null,
startedAt: null,
cancelled: false,
};
activeRun = operation;
state.running.value = true;
All request arguments and cleanup should use the local operation. Global state should be released only when that operation still owns the claim:
finally {
if (operation.tick) clearInterval(operation.tick);
if (activeRun === operation) {
activeRun = null;
state.running.value = false;
}
}
The same claim must cover run(), runVariableSql(), and runScript() so different execution modes cannot overlap. cancel() should consult activeRun, not the UI signal, and cancellation while authentication is pending should prevent the query from starting once authentication resolves.
Tests
- Defer
ensureConfig(), invoke run() twice, resolve it, and assert only one execution starts.
- Repeat for
runScript() and dashboard-variable Run.
- Verify the claim is released after authentication failure and thrown authentication errors.
- Cancel while authentication is pending and assert no request starts after the await resolves.
- Use fake timers and deferred executions to assert no elapsed-time interval leaks.
- Verify a stale finaliser cannot clear a newer operation's
running state, controller, query ID, or timer.
Problem
run(),runVariableSql(), andrunScript()guard re-entry by readingstate.running.value, but they do not claim the execution slot until after awaiting authentication/configuration.For example,
run()currently follows this sequence:runScript()and the dashboard-variable execution path use the same ordering.If two invocations enter while
ensureConfig()orgetToken()is pending, both observerunning === falseand both proceed to execution after authentication completes.This is realistic on the first run of a session, during a genuine token refresh, or through programmatic calls made before the first invocation reaches the
runningwrite.Impact
The Workbench session stores active-run bookkeeping in four shared closure slots:
runT0runQueryIdrunTickabortControllerA second admitted invocation overwrites all four values. The two independent
finallyblocks then clear whichever operation currently owns the shared slots rather than the resources belonging to that invocation.Concrete consequences include:
state.running.value = falsewhile the second request is still streaming;cancel()becomes a no-op after the prematurerunning = false, or cancels only the most recently published controller/query ID;runScript()calls can execute the sameINSERT, DDL, or other side-effecting statements twice.The existing guard test only covers a call made when
runningwas already true. It does not defer authentication and launch competing calls.Suggested fix
Claim the execution slot synchronously before the first
await, and represent lifecycle state with a per-invocation object rather than shared scalar fields.For example:
At each execution entry point:
All request arguments and cleanup should use the local
operation. Global state should be released only when that operation still owns the claim:The same claim must cover
run(),runVariableSql(), andrunScript()so different execution modes cannot overlap.cancel()should consultactiveRun, not the UI signal, and cancellation while authentication is pending should prevent the query from starting once authentication resolves.Tests
ensureConfig(), invokerun()twice, resolve it, and assert only one execution starts.runScript()and dashboard-variable Run.runningstate, controller, query ID, or timer.