Skip to content

fix: fix Web Console hanging or signing out when SSO tokens expire - #617

Merged
glasstiger merged 31 commits into
mainfrom
feat/qwp-entra-token-provider
Oct 21, 2026
Merged

glasstiger merged 31 commits into
mainfrom
feat/qwp-entra-token-provider

Conversation

@glasstiger

@glasstiger glasstiger commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Tandem: questdb/questdb#7799, questdb/questdb-enterprise#1267

Extracted from #603. This PR contains only the console changes the two server PRs depend on; everything else stays in #603.

Why the server change needs it

questdb/questdb#7799 and questdb/questdb-enterprise#1267 tie HTTP sessions to the credentials that opened them. A request that carries the qdb_session cookie and an Authorization header the server rejects is no longer served from the cookie: it gets 401, and the session is closed. Until now, that cookie fallback hid stale bearers from the console. From now on, the console has to send a valid bearer and recover from a 401 itself.

Changes

  • ID-token bearers refresh on their own expiry. With acl.oidc.groups.encoded.in.token=true the bearer is the ID token. The console now schedules its renewal from the ID token's exp claim, instead of the access token's expires_in. A provider that omits the ID token gets an explicit "Missing ID token" error instead of Bearer undefined.
  • The bearer is read when each request is sent. That covers SQL, validation, CSV check/import and preference requests.
  • The login method is remembered per tab. Stored REST/Basic credentials from another tab no longer replace an SSO bearer. Only a tab signed in with a password removes them when it logs out. An SSO tab leaves them to the tabs signed in with them, and reloading it after its session ended resumes SSO or shows Login, instead of signing in with them.
  • Bounded, shared token refresh. This also fixes the original hang, where a failed refresh left every query waiting forever.
    • Queries wait up to 10s for a renewal, before their first request and again after a 401. A rotating refresh can still complete within 60s.
    • Transport, timeout, 429 and 5xx failures back off without logging out.
    • After a failed renewal, an expired bearer is never sent; such requests fail locally with "the request was not executed".
    • A statement the user runs again after the backoff retries the renewal. If the timed-out renewal is still waiting for the identity provider, the statement waits until it is answered or given up, 60s after it started, and then renews. Background reads fail at once instead.
  • 401 recovery.
    • A 401 executed no SQL, so the console refreshes and resends once.
    • A 401 answered for an older bearer no longer logs the user out. Only a rejection of the current bearer does.
    • An OAuth rejection of the refresh still returns the user to sign-in.
    • A 401 from any endpoint also closes the cookie session, and Enterprise deletes the qdb_session cookie. Downloads authenticate with that cookie alone, so the next SQL request asks the server for a new session (session=true). It does the same if the request that opens the session at SSO sign-in is rejected or can't be sent.
  • Failure UX.
    • A persistent renewal warning with "Sign in again".
    • A recovery view with Retry, instead of a blank console, when the startup check fails. Retry renews the session at once, or waits for a renewal still in flight.
    • Sign-in failures (unreachable or slow token endpoint, invalid response) are reported on the login page.
    • A failed renewal is reported in the query's notification only, like an ended session, instead of being marked in the editor as a SQL error.
    • The Schema panel shows a failed renewal, instead of a blank tree, and reloads once a request succeeds.
  • Ending a session.
    • Uploads and settings saves that are already being sent finish: aborting a CSV upload mid-stream can leave a partially imported table. Login shows how many are still being sent, and so does the console after signing in again. The browser asks before leaving the page until they finish. Queries, and writes still waiting for renewal, are cancelled.
    • Toolbar logout reloads at once, which cancels everything still running. It asks first while statements or writes of this session are running, and keeps the session if declined.
    • The last result, and the tables and columns that autocomplete, the AI assistant and CSV import read, are cleared, so whoever signs in next doesn't see them. Query notifications stay: they say what the end of the session interrupted. A logout that reloads leaves the store to the reload.
    • Notebook cell runs and auto-refresh rounds are superseded first, so their aborted results don't overwrite snapshots. So are headless agent runs that haven't launched or only read. A launched headless run that writes records the statements that completed and the interrupted one as unverified, so that an agent doesn't repeat a committed write; its remaining statements are not sent.
    • An interrupted editor, script or AI chat query says whether it may have executed or was not executed, instead of asking to sign in again after the user already has. A script stops before its next statement.
    • If Settings recovery shows Login because another tab removed the shared password credentials, the tab's login ends first, so the next sign-in starts a new session.
  • Notebook refreshes that aren't executed. After a failed renewal, auto-refresh rounds keep a chart's frame and saved snapshot, and a grid slot's rows, until a round reaches the server. A chart with no frame it can draw shows the failure, instead of "Drawing…" forever, and still keeps its snapshot.
  • CSV import with delayed table checks. A table check can wait for a renewal. Its result now updates only the status of the file and name it checked, so running uploads, edits and files added meanwhile are kept. A renamed target shows "Checking table name…" and can't be uploaded until its check answers. A file whose check fails is listed as "Could not check table name", instead of being dropped or keeping the old name's status, and a file dropped again while its check waits is reported as a duplicate.
  • Telemetry shares the console client. It no longer sends a bearer captured at startup, which the server would now reject with a 401 and log the user out. Its hourly reads never renew the session. Only the one-off config read at startup may, like the console's other reads, so that Metrics isn't left unavailable when the token is near expiry or a renewal has failed.
  • Read-only requests are marked as background. Logout and "Sign in again" then only warn about statements and writes that were actually sent.
  • Enterprise e2e lane.
    • .github/workflows/enterprise_e2e.yml dispatches the Azure lane that questdb-enterprise#1267 adds.
    • The lane picks the Enterprise branch with the same name as the UI branch. This branch is named feat/qwp-entra-token-provider for that reason, so it runs against the tandem server.
    • Its oidc.spec.js checks the 401 recovery against a real server, with /exec unstubbed, including that a cookie-only export works afterwards. The required auth lane replays that startup sequence with stubs, for both bearer kinds.
    • e2e/commands.js execQuery now sends the stored REST token for Enterprise password logins.
  • e2e/questdb is bumped to OSS master (f21c6570). fix: fix Web Console hanging when SSO token refresh fails #603 pins e4da5b45, 4 commits behind. "Check if OSS is on master" requires every PR's pointer to be within 5 commits of OSS master, and main's pointer is 31 commits behind. The check now runs on Python 3.12, because pygit2 1.14.1 has no wheels for the newer runner image's Python.

Left in #603

Kept out of this PR because they aren't needed for the server change:

  • the rest of fix: fix Web Console hanging when SSO token refresh fails #603's session isolation in the editor, scripts, AI chat, notebooks, schema, CSV import and charts, including its reset of the whole query state when the session ends (this PR clears only the result, tables and columns);
  • loading-notification history changes;
  • notebook persistence and the Dexie v10 index;
  • the Alt+N notification shortcut, toast focus and theme tokens;
  • schema-tree keyboard handling;
  • the Settings error view redesign;
  • AI chat deletion race, legacy grid stale pages, request-limiter queue.

The hybrid files carry only the part needed here; #603 keeps the rest.

  • Toast/index.tsx: only REFRESH_FAILED_TOAST_ID.
  • refreshFailedWarning.tsx: no Alt+N instructions.
  • index.tsx: the ToastContainer move, and clearLoginDataOnSessionEnd(store.dispatch), which clears the last result, tables and columns when the session ends.
  • Chart and quick-vis: only the background read flag.
  • Import, Result, Schema and the notebook refresh engine: the background read flag and the ssoAuthState rename, plus the session-end, renewal and table-check handling described above.
  • auth.spec.js: only the tandem tests. The "Sign in again" focus test clicks the warning directly instead of using Alt+N.

Once this is merged, merge main into #603. I rehearsed this at 58add5e2: it had 6 conflicts (auth.spec.js, Toast/index.tsx, refreshFailedWarning(.test).tsx, AuthProvider.test.tsx, Schema/index.tsx), and resolving them with #603's version gave #603's tree. #603 has none of the commits added here since (05335a00 to 93d55dd2), and with #603 at 76401b41 the merge now conflicts in 41 files. Resolving those with #603's version would drop these fixes, and merging #603 would then revert them on main. Keep them when resolving, or port them to #603 first. Two of the conflicts need a choice rather than a side:

Merge order

  1. feat(qwp): add refreshing token providers for Entra ID authentication java-questdb-client#102
  2. This PR. Then release a console that includes it.
  3. fix(core): breaking change 💥 - tie HTTP sessions to their credentials and stop reused JSON parsers growing their copy buffer - OSS tandem questdb#7799, after bumping web.console.version to that release
  4. questdb/questdb-enterprise#1267

Validation

At 93d55dd2:

  • yarn typecheck, yarn lint and yarn build pass.
  • yarn test:unit passes: 2,996 tests in 149 files.
  • yarn cypress run --spec 'e2e/tests/auth/*.spec.js' --browser electron against the production preview: 48 passing (44 auth, 4 unauthorized-recovery), none retried.

Not run locally:

  • the console e2e suite against a real server (CI runs it);
  • the Enterprise lane, which needs ENTERPRISE_E2E_ENABLED (CI skips it until that is set) and the pipeline from questdb-enterprise#1267, including its checks that the resend re-opens the cookie session. As configured there, it uses groups.encoded.in.token=false, so the ID-token path is covered only by unit tests and the stubbed browser tests.

Extracted from #603: the console half of questdb/questdb#7799 and
questdb/questdb-enterprise#1267. Those PRs stop serving a request from the
qdb_session cookie when its Authorization header is rejected, so an expired
or stale bearer now gets a 401 and closes the session instead of being masked.

- Refresh an ID-token bearer (acl.oidc.groups.encoded.in.token=true) by the
  ID token's exp claim rather than the access token's expires_in, and report
  a missing ID token instead of sending "Bearer undefined".
- Read the bearer at send time for SQL, validation, CSV check/import and
  preference requests, and never let stored REST/Basic credentials replace
  an SSO bearer.
- Coordinate token refresh in one bounded, cancellable place: queries wait up
  to 10s, a rotating refresh may still land within 60s, transient failures
  back off without logging out, and an expired bearer is never sent after a
  failed renewal (fixes the refresh deadlock that froze every query).
- Recover from a rejected bearer: refresh and resend once (a 401 executed no
  SQL), ignore 401s answered for an older bearer, and log out only when the
  current bearer is rejected.
- Show a renewal warning and a recovery view with Retry/Sign in again instead
  of a blank console, and report sign-in failures.
- Share one client between the console and telemetry so telemetry never sends
  a stale bearer, and mark read-only requests as background so logout warnings
  only count statements and writes.
- Dispatch the Enterprise Web Console e2e lane added by
  questdb-enterprise#1267, send the stored REST token in e2e execQuery, and
  extend the unit and stubbed browser auth tests.
@glasstiger glasstiger added bug Something isn't working web-console Issues relevant to "web-console" package security labels Oct 19, 2026
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Web Console deploy preview

Preview Commit Logs
https://pr-617--web-console.netlify.app 72ba25b build log

…he session ends

- Let uploads and settings saves that are already being sent finish when
  the session ends, instead of aborting them mid-stream: an aborted CSV
  upload can leave a partially imported table. Their results are still
  rejected as session-ended and no longer count as the next login's
  activity. Queries, and writes still waiting for renewal, are cancelled.
- Show a failed SSO renewal in the Schema panel and reload it once a
  request succeeds, instead of leaving the tree blank.
- Supersede notebook cell runs, auto-refresh rounds and headless agent
  runs before session end aborts their requests. Their aborted results
  no longer overwrite the last good snapshot, save refresh errors, delete
  chart snapshots or record a possibly committed write as a failed run.
- Cover the session-end client behaviour, Schema recovery, notebook
  session end and run_query's unverified classification, and add an e2e
  test for renewing and resending an editor statement once after a 401.
Every 401 in the auth e2e tests was stubbed, so nothing checked the tandem
server change (questdb/questdb#7799, questdb/questdb-enterprise#1267)
against a real server.

The new Enterprise OIDC test leaves /exec unstubbed. After a reload, silent
re-auth installs a bearer the userinfo fixture rejects, while the browser
still sends the qdb_session cookie of the earlier sign-in. The test checks
that the server answers that session start with 401 instead of serving it
from the cookie, and that the console renews its token once and resends its
startup check once, then keeps working as the same user.

Document this coverage, and that ID-token bearers still need an Enterprise
lane with a fixture that signs ID tokens.
"Check if OSS is on master" failed on runners with the ubuntu-26.04 image,
whose default Python is 3.14. pygit2 1.14.1 ships wheels only up to CPython
3.12, so uv built it from source and failed on the missing libgit2 headers
(git2.h). Runs on ubuntu-24.04 still passed.

Pin the interpreter so uv installs the prebuilt wheel on every image. The
check itself is unchanged; the e2e/questdb pointer is the OSS master head.
Ending a session lets uploads and settings saves that are already being
sent finish, because aborting a CSV upload mid-stream can leave a
partially imported table. But "Sign in again" told users those uploads
"may still finish", and the Login view's next step, signing in with SSO,
navigates away and cancels them.

- Keep counting sent writes after their session ends, until they settle.
- Show that count on the Login view, and ask the browser to confirm
  leaving the page (SSO redirect, reload or closing the tab) until they
  finish. Logging out from the toolbar, which reloads at once, is
  unchanged.
- Say in the "Sign in again" confirmation that uploads continue only
  while the tab stays open.
The only check of the tandem server change (questdb/questdb#7799) against
a real server is in the Enterprise lane, which CI skips unless
ENTERPRISE_E2E_ENABLED is set. Replay its startup sequence with stubs, for
access-token and ID-token bearers: the server rejects the new bearer of
both the session start and the startup check, and the console renews its
token once, resends the check once and keeps the session.
A table check can now wait up to 10s for a token refresh. The Import view
then replaced the whole file list with a copy taken before the wait. That
reset a running upload to "Upload", so a second click could import the file
twice. It also reverted edits and dropped files added meanwhile.

- Merge only the checked status into the current list, and ignore a status
  checked for a name the file no longer has.
- Apply renames and other edits at once, so an upload started during the
  check uses the new name. Only the status waits for the check.
After a 401, a query waited for the token refresh until its 60s network
deadline. Queries waiting before their first request gave up after 10s,
when the console already reported the renewal as failed, so a rejected
statement kept running next to the failure warning.

After a 401, wait no longer than before the first request. The refresh
itself still runs to its network deadline, so a late rotated token is still
installed for the next request.
Ending the session without a reload rejects the queries still running with
"Your session has ended. Please sign in again." The editor and the AI chat
stored that as the query's notification, so after signing in again with a
password, the console still asked the user to sign in.

- Editor, script and AI chat runs now say that the query was not run, or
  that it was interrupted and may have executed. This also replaces the
  "Running..." notification, whose Stop button would otherwise start a query.
- A result page read interrupted by the session end adds no notification.
A failed SSO renewal is shown in the Schema panel. After the renewal
recovered and the tables loaded, a later unrelated failure still showed
"Could not renew your SSO session".
Identity providers often omit the full stop, e.g. Keycloak's "Token is not
active", which read "Token is not active Please sign in again."
A 401 for a rejected bearer closes the server's cookie session, and the
Enterprise server deletes the qdb_session cookie with every 401. The console
renewed its token and resent the query, but never asked for a new session.
Downloads authenticate only with that cookie, so CSV and Parquet exports
failed with "Unauthorized" until the page was reloaded.

After a 401, the next SQL request of the same SSO login now sends
session=true, one request at a time, until the server accepts one. The
server opens a session for it only when the browser sent no cookie, so the
parameter changes nothing while the cookie is still valid.
Renaming the target table of a queued CSV applied the new name at once, but
kept the old name's status until the existence check answered. The check
can wait up to 10s for a token renewal, and a failed check never answered.
A file renamed to an existing table could show "Ready to upload", and its
upload appended to that table without the "Table already exists" warning.

The new name now shows "Checking table name…" and can't be uploaded until
its check answers. A failed check shows "Could not check table name"
instead of the old status.
Ending a session lets uploads and settings saves that are already being
sent finish, and Login asked before leaving the page until they did. But
signing in again with a password replaces Login without leaving the page,
so the notice and the leave-page prompt disappeared while the upload was
still being sent. A reload then cancelled it without asking, which can
leave a partially imported table, and the empty Import view invited
importing the same file again.

- Ask before leaving the page in every view, not only on Login, until the
  previous session's writes settle.
- After signing in again, show a warning with their live count in the
  console until they settle.
- Logging out from the toolbar still reloads at once. Asking there could
  keep a console without a session, as Login is not shown before the
  reload. The warning says that logging out cancels them.
Ending the session aborts a running script's statement and stops the
script before its next one, but nothing tested it, and the end of the run
failed: the editor is unmounted while the script settles, and revealing
the position of an editor without a model threw "Invalid arguments". The
run was never settled, so the editor stayed marked as running a script
and the query log missed its "Stopped after..." summary.

- Settle the run when the editor no longer has a model.
- Check for a stop before the first statement too, so a session that
  ends while the script waits to start sends nothing.
- Test both ways a session ends during a script, with stop after failure
  off: signing in again while a statement runs, and a 401 that signs the
  user out. The script stops before its next statement, and the log says
  whether the interrupted statement may have executed or was not run.
The three .gitleaksignore entries added here fingerprint lines 102-104 of
e2e/tests/enterprise/oidc.spec.js in commit 0f4d4ac. That commit is on
the #603 branch, not in this branch's history, and those lines hold no
token here. Every token this branch adds to oidc.spec.js is marked with
gitleaks:allow, so the entries suppressed nothing. #603 carries its own
copy of them.
The server closes the session of a request whose bearer it rejects, and
Enterprise deletes the qdb_session cookie with every 401. The console
asked for a new session (session=true) only after an /exec 401. Live
validation, CSV checks, uploads and settings saves send the same cookie,
and validation runs while the user types. If one of them saw the 401,
and the bearer was renewed before any query was rejected, no query asked
for a session. CSV and Parquet exports, which authenticate with the
cookie alone, then kept failing.

Record the lost session whichever request saw the 401. While the cookie
is still valid, asking for a session changes nothing.
A table check can wait for a token renewal and then fail, e.g. after a
failed SSO renewal. Only a renamed file handled that. A dropped file was
silently lost: it never appeared, and no error was shown. And one failed
recheck, when the Import view became visible, discarded every other
file's new status.

- List a dropped file whose check fails as "Could not check table name",
  like a renamed one, and let it be uploaded.
- Keep a file for an existing table whose schema can't be read, without
  a pre-filled schema. QuestDB then infers it from the file.
- Keep a file's status when its recheck fails, and merge the others.
- Count dropped files as queued at once, so that dropping one again
  while its check waits is reported as a duplicate, not queued twice.
After a failed SSO renewal the user stays signed in, but statements are
not executed. Auto-refresh rounds treated that like a failed query. A
chart's frame was replaced with errors and its saved snapshot deleted,
and a grid saved "Could not renew your SSO session" as a refresh error,
shown again after the next sign-in. Rounds cut off by the end of the
session are already dropped.

Treat a statement that was not executed like an aborted one. A chart
keeps its frame and its saved snapshot. One with nothing to show yet
shows the failure, but still keeps its snapshot. A grid slot keeps its
rows, and the round saves nothing.
A failed SSO renewal was marked in the editor like a SQL error: a red
marker on the query's first word, with the cursor moved there. A session
end already only replaced the "Running..." notification.

The messages also said "not sent" of requests that the server had
received and rejected with 401 or 403. What holds in every case, and
tells the user that a retry is safe, is that nothing was executed.

- Show a failed renewal in the notification only, for single runs and
  scripts, without marking the query.
- "Not executed: your previous session ended without running this
  query." replaces "Not run: your previous session ended before this
  query was sent."
- A failed renewal says "the request was not executed" instead of "the
  request was not sent", and the recovery view no longer says "No
  request was sent."
The store outlives the console: signing in again with a password
remounts it in the same document, without a reload. The result grid then
showed the previous login's last rows, possibly to another user, and
scrolling re-ran its query with the new credentials.

Clear the result when the session ends. Notifications stay: they tell
whoever signs in next what the end of the session interrupted. Also
correct the logout comment, which described a store reset that did not
happen.
@puzpuzpuz

puzpuzpuz commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Review of PR #617, level 3 (full review)

Reviewed at ee4d6317. The full level-3 process ran: 13 parallel review agents, including a fresh-context adversarial pass, then one verification agent per behavioral finding. The structural and test-coverage findings were checked directly against the source.

Quality gate: everything passes.

  • yarn typecheck, yarn lint (including the color-literal check) and yarn build all pass.
  • yarn test:unit passes: 149 files, 2,956 tests.
  • No quality-check rows appear in the table below.

PR title and description

  • The title follows the conventions: Conventional Commit type, the verb repeated, and it describes the user-facing impact.
  • Two details in the description are out of date:
    • It says the e2e/questdb pointer is e4da5b45. The branch actually points to f30fd1af, which is the current OSS master HEAD, so the pointer itself is fine.
    • The validation numbers (2,931 tests in 147 files at 6df38c5e) are from an older commit.

Issues

ID Issue Category Severity Location Description Steps to reproduce Suggested fix
1 Headless run drops committed writes Persistence & migrations Moderate in-diff: src/utils/notebooks/notebookHeadlessRun.ts:214-224 The new authSessionState.beforeEnd hook aborts every headless claim when a session ends without a reload. That covers a 401 after failed renewal, "Sign in again" and a 403 logout. enqueueBufferTask (:517) then sees !claim.isCurrent() and returns committed:false, so neither commitView nor persistCellSnapshot runs.
As a result, statements the server already committed are never recorded, for example the first INSERT of a multi-statement cell. The cell keeps the previous run's lastRunStatus/snapshot, and the agent conversation that received the summary unmounts with Login.
On main the run finished and its real per-statement outcome was persisted. Dropping the in-flight statement is deliberate (notebookDexieController.test.ts:1094), but completed statements are dropped with it, so an agent or user can re-run writes that already committed and duplicate data.
• Have the agent run a headless cell INSERT INTO t VALUES(1); INSERT INTO t VALUES(2) on a notebook that isn't open
• While the 2nd INSERT is in flight, click "Sign in again" (or force a 401 logout)
• Sign back in and open the notebook: the cell shows the old "success", and t contains the new row(s)
In beforeEnd, cancel only claims that haven't launched. For launched claims, let the commit go through with the completed statements and mark the in-flight one sessionEnded; isUnverifiableExecError already treats that as unverified. Add a test where statement 1 commits before the session ends.
2 Chart spins forever after SQL edit Query execution & data integrity (also found by the fresh-context agent) Minor in-diff: src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts:1296-1302 if (notExecuted && current != null) return is meant to keep the current frame on screen. After an SQL edit, though, current is the old SQL's frame (toChartResult returns stale), and the early return skips both setCellResult and settledKey. deriveChartLoading stays true, so the chart shows a spinner with no error and no Retry. With auto-refresh off, nothing ever retries.
On main this case wrote an error frame. It is reachable when the edited SQL is already in statementClassCache, so validation is skipped, while renewal has failed and the token has expired. New SQL shows "Cannot classify…" instead.
• Chart cell: edit select A → select B so both are classified
• Make the IdP refresh fail and let the token expire
• Edit back to select A: the spinner never clears
Keep the frame only if it matches the current queries: notExecuted && current != null && resultMatchesQueries(current, queries). Otherwise fall through, write the error and set settledKey, while still skipping the snapshot. Add an engine test for "edit + not executed + stale frame".
3 CSV duplicate-drop guard is overwritten React correctness & hooks (found independently by 4 agents) Minor in-diff: src/scenes/Import/ImportCSVFiles/dropbox.tsx:65-77 vs :98-100 The PR adds dropped names to filenames.current immediately, so that a second drop made while the table check waits on a refresh (up to 10 s) counts as a duplicate. But the existing useEffect(() => { filenames.current = files.map(...) }, [files]) replaces the ref on any change to files, which drops the pending names. files changes on:
• upload progress
• handleVisible
• another drop resolving
• a rename
Separately, React 17 doesn't batch the Upload→list view switch, and the new DropBox re-initializes its ref from files.
The same file is then queued twice, each copy with its own crypto.randomUUID() id. Uploading both imports the rows twice, and removing one row removes both (onFileRemove filters by name). This is an incomplete fix rather than a regression: main had no guard at all.
• Start uploading a large CSV
• While a refresh is pending (or chk is slow), drop c.csv, then drop c.csv again before it is listed
• Both entries appear; upload both and the rows are imported twice
Track pending names in a separate ref that the [files] effect doesn't reset, lifted into ImportCSVFiles so it survives the DropBox remount. Check duplicates against files ∪ pending, and clear a name when its handleDrop settles. Add a test that changes files between the two drops.
4 "Sign in again" clears other tabs' SSO resume Persistence & migrations Minor in-diff: src/providers/AuthProvider.tsx:583-604 → logout :503-505 signInAgain passes clearSSOSession:true, which removes the shared localStorage SSO_SESSION_ACTIVE. Silent SSO in other, healthy SSO tabs requires that flag (:381-386). Any of those tabs that reloads before tab A finishes SSO (or before another tab refreshes) lands on Login, or on another tab's stored password identity.
On main, only the explicit toolbar logout and the 5×-401 guard cleared this flag. "Sign in again" (from the toast or the recovery screen) is meant to re-authenticate one tab.
• Two SSO tabs
• In tab A, make the refresh fail and click "Sign in again"
• Reload tab B: it shows Login instead of resuming silently
Don't pass clearSSOSession from signInAgain. Tab A's own LOGIN_METHOD is cleared anyway, and prompt=none falls back to prompt=login. Add a test that SSO_SESSION_ACTIVE survives signInAgain.
5 Failed table check imports with new-table defaults Query execution & data integrity Minor in-diff: src/scenes/Import/ImportCSVFiles/index.tsx:114-121, 150-196 A TableNameCheck.FAILED file now stays in the list and can be uploaded. Before this PR, a failed check rejected the whole drop. Every derived field is gated on EXISTS, so the file gets exists:false, schema:[], partitionBy:"NONE", no timestamp and no TTL, and partitionBy=NONE is sent to /imp.
If the table actually exists and the user picks Overwrite, the table is likely rebuilt unpartitioned with no designated timestamp. Server behavior isn't verified here.
A later successful re-check (handleVisible or a rename) updates only status, not exists/schema/partitioning. The rename half of this gap already exists on main.
• Make one chk request fail, then drop trades.csv, where table trades.csv exists and is partitioned
• The row shows "Could not check table name"
• Choose Overwrite and upload
When a check later returns EXISTS, recompute exists/schema/partitionBy/timestamp/TTL, in both handleVisible and the rename path. Alternatively, block Overwrite, or omit partitionBy, while the status is FAILED.
6 Hung IdP makes Retry fail instantly for up to 60 s Async, timers & cancellation Minor in-diff: src/utils/questdb/tokenRefresh.ts:118, 184-202; copy in refreshFailedWarning.tsx:38, AuthCheckRecovery.tsx:64,104 When the token endpoint hangs, the soft deadline reports failure at 10 s. The pending slot, however, is held until the 60 s hard deadline (deliberately, to keep a late rotated token).
Meanwhile, once the token has expired, start() returns early (pending is set), the soft deadline has already resolved, and ensureUsableToken throws. Every Retry therefore fails instantly, with no new token request, for about 30–50 s.
The UI says "Retry them to renew" and "Retry after a short wait", and nothing signals when the slot frees.
• SSO session with an expired or near-expired token
• Make the token endpoint stall (no response)
• After the toast appears, retry every few seconds: instant failures and no token request until about 60 s
Don't start a parallel refresh. Either make a retry that finds a held slot past its soft deadline wait on hardDeadline (surfacing it via onRefreshWait), or set retryAfter to the hard deadline and adjust the copy. Optionally publish an event when the slot frees.
7 Schema fallback query sent after logout Async, timers & cancellation Minor in-diff: src/scenes/Schema/index.tsx:242-267 Ending the session now rejects in-flight queries as sessionEnded. fetchColumns' catch treats that like an unsupported function and sends information_schema.columns() after logout. The request has no Authorization header (capture() takes the new, empty session); the cookie may still authorize it, racing destroyServerSession.
Effects:
• A 401 is ignored.
• A 200 writes the old login's columns back into Redux.
Nothing is visible, but the request is unauthenticated, made after logout, and avoidable.
• Throttle the network so questdb_columns() stays pending
• Log out (or hit a 401) meanwhile
• The Network tab shows information_schema.columns() sent after session=false, without a bearer
In the fetchColumns (and fetchTables) catch, return on isSessionEnded(error), and stop the fallback on isAuthFailure(error).
8 Catalog panels blame the network for a renewal failure Cross-context caller impact Minor out-of-diff: src/scenes/QueryActivity/index.tsx:182-188, src/scenes/Schema/TableDetailsDrawer/index.tsx:765-769. Contract (2.5c): an AuthRefreshUnavailable rejection is not a connection failure and publishes no MSG_CONNECTION_ERROR. When renewal has failed and the token has expired, catalog reads reject with authRefreshUnavailable. useCatalogSource records a generic failure and moves the source to unavailable, and both panels show "The console cannot reach the server". At the same moment the footer says "Connected" and the toast and Schema correctly say the SSO session couldn't be renewed. Users are pointed at a network problem instead of "Sign in again".
This is not a regression: main showed the same text after a 10 s spinner.
• SSO with the token endpoint returning 5xx; wait for expiry
• Open Query Activity, or Table Details for an unloaded table
Carry the failure reason through useCatalogSource (e.g. an authRefreshUnavailable flag on the unavailable state) and show the renewal message in both panels, or use neutral wording.
9 Previous user's schema survives re-login State & context architecture Minor out-of-diff consumers of Redux query.tables/columns (useSchemaCompletionProvider.ts:9, AIChatWindow/index.tsx:239, MCPBridgeProvider/useBridgeToolRunner.ts:16, ImportCSVFiles/index.tsx:44). Contract: session-end store reset. The new clearResultOnSessionEnd clears only query.result, although its comment promises the next login won't see the previous login's data. Tables and columns survive a non-reload logout followed by a password sign-in as another user in the same tab. They feed autocomplete, AI chat/MCP context and Import until the new user's Schema fetch succeeds, and indefinitely if it fails.
The leak exists on main. The PR adds a new same-document path to it ("Sign in again").
• User A loads the schema
• Session ends without reload (401/403/"Sign in again")
• User B signs in with a password and can't read tables(): A's tables show in autocomplete and AI context
Generalise the hook into a session-end store reset that also dispatches setTables([])/setColumns([]) (or a RESET_SESSION_STATE case), and extend clearResultOnSessionEnd.test.ts.
10 Ended-session upload outcome never shown Query execution & data integrity Minor in-diff: src/utils/questdb/client.ts:977-999; AuthProvider.tsx:184-191; unfinishedWritesWarning.tsx:14-19 Uploads that were already sending are kept running across session end (by design). When one completes, onload rejects with SessionEndedError even on HTTP 200, so the UploadResult (OK / rows rejected / error) is discarded. The Login notice and the console toast then simply vanish when the count reaches 0, because the toast effect dismisses on !hasUnfinishedWrites.
The PR's own copy warns that re-importing can duplicate rows, but the user is never told whether the import succeeded.
The result was also lost on main; the PR adds the warning but not the outcome.
• Start a large CSV upload
• "Sign in again" while it sends, then sign in with a password
• When the import finishes, the toast disappears with no result
Record a per-write outcome summary (table, OK/rejected rows/HTTP status, or "unknown") when an ended-session write settles. Show it in a final toast that isn't auto-dismissed at count 0.
11 Fast browser clock causes a refresh every 30 s Performance & rendering at scale Minor in-diff: src/modules/OAuth2/utils.ts:103-127; src/utils/questdb/tokenRefresh.ts:156-168 With groups.encoded.in.token, expiry is now the ID token's absolute exp compared against the browser clock. main used now + expires_in, so skew had no effect there.
If the browser clock is ahead by about one token lifetime (e.g. an hour-sized time-zone/RTC error), every new token looks expired. Any request then rotates the refresh token about every 30 s while pollers run, and one request per cycle waits on the refresh.
Two side effects:
• A single transient refresh failure blocks all queries for the backoff period, because ensureUsableToken sees the token as "expired".
• Telemetry (refreshAuth:false) silently never sends.
The 30 s bound is deliberate and tested (client.auth.test.ts:341).
• ID-token mode; set the OS clock about 1 h ahead; sign in
• Leave Query Activity or Metrics open
• A refresh_token POST appears about every 30 s
Estimate skew from the token response Date header (or iat relative to receipt time, while tolerating backdated iat). Or have ensureUsableToken refuse a locally "expired" bearer only after the server has actually returned 401 for it.
12 Persistent auth toasts unreadable after a theme switch Styling & theming Minor out-of-diff root cause: src/components/Toast/index.tsx:67-128, which bakes getThemeColor() strings into style when the toast is created. New consumers: refreshFailedWarning.tsx:36-41, unfinishedWritesWarning.tsx:16-30 These are the first autoClose:false toasts. Their <Text color="contentPrimary/contentSecondary"> follows the live theme, but the toast background keeps the theme from creation time.
After a switch, primary text and background are nearly identical: #1c2029 on #1d1e24, or #f8f8f2 on #f4f5f7. The "Could not renew" and "still sending N requests" warnings become unreadable.
The default "system" preference switches automatically with the OS.
• Dark theme; trigger a refresh failure so the persistent toast shows
• Switch to the light theme (in-app or OS)
• The toast text disappears
Use CSS variables (var(--qdb-color-…)) in the Toast wrapper's style, or re-issue toast.update on MSG_THEME_CHANGED.
13 401 after a resend shows as a SQL error Query execution & data integrity Minor in-diff: src/utils/questdb/client.ts:723-738 When a resend's 401 isn't published, because the bearer rotated again during the resend, the rejection is a bare {status:401, error:"Unauthorized"} with no position/type/requestNotSent.
Consequences:
• Monaco stores it as a SQL error and squiggles the first word.
• The notebook refresh shows "Unauthorized" instead of keeping its frame.
It's a narrow race, and the same shape main produced for every 401, but it is inconsistent with the PR's own "not executed" contract.
• The server rejects freshly rotated bearers
• Q1 resends with B; a concurrent 401 rotates the bearer to C before Q1's response arrives
In the 401 branch, add position:-1, query, type:ERROR, ...sessionFailureOf(execution), or reject as AuthRefreshUnavailableError so isAuthFailure routes it.
14 Renewal recovery screen: wrong label, silent retries Accessibility & UX Minor in-diff: src/providers/QuestProvider/AuthCheckRecovery.tsx:94-111; src/components/ConnectionErrorView.tsx:24 The title says "Could not renew your SSO session", but the button says "Retry connection"; the retryLabel prop exists and is never passed.
During the 5–30 s backoff, Retry fails synchronously (ensureUsableToken) with the same role="alert" text and a pending flip that never paints. Nothing visibly or audibly changes, so the button looks broken.
• Expired token with the IdP returning 5xx; reload so the startup check fails
• Click Retry within about 5 s: no change and no announcement
Pass retryLabel={renewalUnavailable ? "Retry renewal" : undefined}. When retrying during tokenRefresh.inBackoff(), set a distinct status, e.g. "Renewal paused; try again in a few seconds".
15 Two primary buttons on the recovery screen Styling & theming Minor in-diff: src/components/ConnectionErrorView.tsx:65-72 "Retry connection" and the session-ending "Sign in again" both default to primary. That violates Button/README.md ("single main action"); paired actions elsewhere use secondary. • Trigger the "Could not renew your SSO session" startup screen Add variant="secondary" to "Sign in again".
16 Low-contrast text on the new recovery screen (light theme) Styling & theming Minor in-diff: src/components/ConnectionErrorView.tsx:49-60 on CenteredLayout's authBackdrop Measured against authBackdrop #c7cbd2:
• statusDanger #ce1717 (14 px): about 3.43:1
• contentSecondary #505968: about 4.34:1
Both are below WCAG AA's 4.5:1. The token pair is already used by the Settings error view on main; this PR adds a new primary failure screen with it.
• Light theme; block /exec so the auth check fails Use a darker danger/secondary token on this backdrop (e.g. statusDangerSubtle), or lighten authBackdrop in the light theme.
17 Unfinished-writes notice not announced Accessibility & UX Minor in-diff: src/modules/OAuth2/views/login.tsx:381-387, 459-472; unfinishedWritesWarning.tsx The role="status" notice mounts already populated, in the same commit as Login, and focus moves to the error banner, whose aria-describedby covers only the error message. Screen-reader users hear "may still be finishing" but not "keep this tab open / SSO cancels them".
Separately, the console toast (role="alert") re-reads the full paragraph on every count change.
• Start an upload; "Sign in again"; with NVDA/VoiceOver, only the banner is read Add the notice's id to the banner's aria-describedby. Pass role:"status" to the toast, or announce only the count line.
18 "Checking your session…" not announced Accessibility & UX Minor in-diff: src/providers/QuestProvider/index.tsx:178-193 After 500 ms, a new role="status" element is mounted with its text already inside, which screen readers often don't announce. Before 500 ms the page is blank. • Delay SELECT 1 by more than 1 s and load with a screen reader Always render the CenteredLayout with an empty role="status" while the check is pending, and fill in the text after the 500 ms delay.
19 Enterprise dispatch PAT reachable from same-repo PR branches Browser compatibility & security Minor in-diff: .github/workflows/enterprise_e2e.yml:3-31; e2e/README.md:47-56 The workflow runs on pull_request, so the PR branch controls the workflow file. A same-repo branch can drop the if: guard or exfiltrate ENT_DISPATCH_PAT.
Even unmodified, the workflow makes the private Azure pipeline build and run the branch's package.json scripts.
This matches existing practice (deploy_web_console.yml exposes the Netlify token the same way). The new part is a path from public-repo write access into private Enterprise CI. The README's PAT scoping is advisory, not enforced.
• A push-access user opens a branch PR that edits the workflow or postinstall Put the secret in a GitHub Environment with required reviewers or a branch rule. Use a service identity that can only queue that one pipeline. Build the UI in an Enterprise job that has no secrets.
20 Redundant session re-checks Code structure, readability & types Minor in-diff: src/utils/questdb/client.ts:783-784, 911-912, 942-943, 1031-1032 prepareRequest() already runs ensureRequestAllowed. validateQuery, uploadCSVFile and savePreferences immediately call it again (validateQuery with a throwaway signal), and checkCSVFile repeats isCurrent(). Upload and save also ignore the session prepareRequest returns. n/a Remove the second checks and use the session returned by prepareRequest (or pass the captured one in).
21 ErrorResult.connectionError is never read Code structure, readability & types Minor in-diff: src/utils/questdb/types.ts:122, client.ts:595,724 The flag is set in two places, but no production code reads it; only tests do. notebookUtils still detects connection errors by string markers. n/a Remove it, or use it in isUnverifiableExecError instead of the string markers.
22 ErrorResult is a bag of optional flags, forcing casts Code structure, readability & types Minor in-diff: types.ts:118-124, client.ts:714, session.ts:37-50, Schema/index.tsx:171-172, AuthCheckRecovery.tsx:65 Only a few flag combinations actually occur, but the type allows all of them. isSessionEnded/isAuthRefreshUnavailable aren't type predicates, so new consumers cast (as ErrorResult). The 401/403 rejection is an untyped Record, which is how 13's missing fields went unnoticed. n/a Make the helpers type guards, type errorPayload as ErrorResult, and consider a failure discriminant.
23 Session-ended message logic copied 3× Code structure, readability & types Minor in-diff: Monaco/index.tsx:1609-1613, 2220-2224; runDetachedQuery.tsx:144-147 isSessionEnded(e) ? sessionEndedQueryMessage(e) : e.error is duplicated, twice inside the repo's largest file. n/a Add queryFailureMessage(error) to modules/OAuth2/session.ts.
24 Unfinished-writes copy duplicated Code structure, readability & types Minor in-diff: login.tsx:459-471; unfinishedWritesWarning.tsx:24-32 The same singular/plural headline appears in both files, with near-identical bodies that have already drifted apart. n/a Extract an unfinishedWritesHeadline(count) helper or a shared summary component.
25 Inline copy of checkTableName; status still typed string Code structure, readability & types Minor in-diff: ImportCSVFiles/index.tsx:115-121 vs :363-368; types.ts:7 The new helper is re-implemented by hand about 250 lines later. ProcessedFile.status remains string despite the new enum, so mapStatusToLabel can't be checked for exhaustiveness. n/a Call checkTableName there too, and type the field FileCheckStatus | TableNameCheck.
26 CANCELLED_BY_USER migration incomplete Code structure, readability & types Minor in-diff: useChartQuery.ts:17, Monaco/index.tsx:2295, useCellExecution.ts:116,876,986; also StatusNotification.tsx:167 The shared constant and isCancelledByUser were added, but the raw literal remains in files this PR touches. n/a Import the constant and helper at the remaining sites.
27 Production code shaped around test doubles Code structure, readability & types Minor in-diff: executeSingleRaw.ts:61-65, tools/runQuery.ts:118-120 Both files add the same .then(r => r.type === ERROR ? reject : r) "for adapters/test doubles". The real client never resolves an ERROR result. n/a Make the mocks reject and drop the normalizers, or share a single helper.
28 Misleading auth names Code structure, readability & types Minor in-diff: handleUnauthorized.ts:10-12; types.ts:376; client.ts:485-486 handleSSOUnauthorized also handles password sessions. refreshAuth:false additionally disables 401 logout, which isn't documented, and its JSDoc ("Background work") blurs it with background. n/a Rename (e.g. handleUnauthorizedOnOIDCServer, passiveAuth) or document the logout semantics.
29 Getter used only by tests Code structure, readability & types Minor in-diff: tokenRefresh.ts:72-74 get hardDeadline has no production reader. n/a Drop it, or mark it as an internal test hook.
30 ConnectionErrorView API surface; useAuthCheck mixes concerns Code structure, readability & types Minor in-diff: ConnectionErrorView.tsx:18-36; AuthCheckRecovery.tsx:14-80; QuestProvider/index.tsx:181-196 The new shared component has one caller, and its retryLabel/preloadedImages props are never passed. SettingsProvider keeps a separate, different-looking "can't connect" view. useAuthCheck (in a view file) also owns <main> focus. Nested ternaries in JSX. n/a Drop the unused props or move the Settings error view onto it. Split out a useFocusOnReady hook, and use early returns.
31 Large files keep growing Code structure, readability & types Minor in-diff: client.ts 716→1135 lines (sendQuery about 85 lines, 6 positional params); AuthProvider.tsx 468→666; Monaco/index.tsx now 2,742 "sso"/"password" appear as magic strings in 5 places. The "mark authenticated + LOGIN_METHOD=password + ready" sequence is repeated. refreshAuthToken and RefreshTokenMethod take two adjacent, easily swapped AbortSignal parameters. n/a Extract retryAfterUnauthorized() from sendQuery, add a LoginMethod type with get/set helpers and a completePasswordLogin() helper, and pass the signals as a named object.
32 Result clearing never tested end-to-end Test review & coverage Minor in-diff: src/index.tsx:76 clearResultOnSessionEnd.test.ts exercises the helper against its own reducer only. Nothing checks that the real store is wired, so dropping line 76 would leak the previous user's grid rows unnoticed. n/a In auth.spec.js's 401→password re-login scenario, render rows first, then assert no grid-row after re-login.
33 Settings-save session fences untested Test review & coverage Minor in-diff: src/components/TopBar/toolbar.tsx:439-487 No unit test covers handleSaveSettings. The best-effort telemetry read routinely rejects (refreshAuth:false inside the refresh window), so removing its .catch would turn successful saves into "Failed to update…". n/a Add a renderer test: savePreferences succeeds and the telemetry query rejects, so expect a success toast and no error toast. Add a second case for session end mid-save.
34 Grid page session-end suppression untested Test review & coverage Minor in-diff: src/scenes/Result/index.tsx:197-215 If the isSessionEnded(err) early return regresses, a stale "session ended" notification is shown to the next login. n/a Extract paginationFn, or render Result with a queryRaw that rejects {sessionEnded:true}.
35 Editor auth-failure markers untested Test review & coverage Minor in-diff: Monaco/index.tsx:1590, 2209, 1875 e2e checks only the notification text. Nothing asserts that auth failures leave no squiggle or glyph, or covers the "Stopped before running any query" summary. n/a In auth.spec.js:653, assert getErrorMarker() doesn't exist and there are no error icons.

False positives

  • Query execution & data integrity. Draft claim: an editor script keeps running after a statement that renewal failure prevented from executing, so a later statement can run after an earlier one was skipped.
    The stop rule is unchanged from main. Scripts continue past failures only when the user unticked "Stop running after a failed query", or for selected-query runs, which never stop on failure. The renewal failure counts as a failure, is reported per statement with a "not executed" notification, and with default settings stops run-all. Recovering mid-script is also nearly impossible, because the failure is synchronous during backoff.
  • Test review & coverage. Draft claim: the e2e/questdb pointer (f30fd1af) differs from the e4da5b45 named in the description, so CI may test against a different server than the one validated.
    f30fd1af is the current OSS master HEAD (compare … master → identical), so the pointer is correct. Only the PR description is stale.
  • Accessibility & UX (part of 14). Draft claim: the recovery status text duplicates the button label.
    This holds only for the plain connection-error branch. In the SSO-renewal branch the status reads "Retry after a short wait, or sign in again."

Summary

  • Verdict: request changes (minor). The PR fixes the real infinite hang and the spurious logouts. The core refresh, 401 and session-fencing machinery in client.ts/tokenRefresh.ts held up under every path traced:

    • no double execution on resend
    • requestNotSent correct on every branch
    • stale-bearer 401s no longer log users out
    • React 17 batching handled explicitly with unstable_batchedUpdates

    Please fix or explicitly accept these before merge; they are cheap:

    • 1: decide whether headless runs should drop committed statements.
    • 2: guard the chart frame match.
    • 3: make the duplicate-drop guard survive files updates.
    • 4: drop clearSSOSession from signInAgain.
    • 7: stop fetchColumns on session end.
    • 14/15: label and variant fixes.

    The rest can be follow-ups.

  • Regressions and tradeoffs:

    • 1, 2 and 4 are behavior regressions versus main.
    • 3, 5 and 10 are incomplete fixes or new gaps around CSV import created by the new "keep going through refresh waits" behavior.
    • 8, 9 and 13 are pre-existing behaviors that the PR's new contracts make inconsistent.
    • 6 and 11 are consequences of deliberate, tested design choices (the 60 s hard refresh slot and absolute ID-token exp) whose edge cases need UX or skew handling.
  • Findings: 37 draft findings after deduplication. 35 verified (1 Moderate, 34 Minor, 0 Critical) and 2 dismissed as false positives (one more partly dismissed, noted under 14).

  • Location: 32 in the diff, 3 outside it (8 QueryActivity/TableDetailsDrawer, 9 Redux schema consumers, 12 the Toast wrapper). The cross-context pass checked every callsite in the inventory, including all 14 queryRaw callers, all 29 QuestContext consumers, every abort(id) caller, getResult consumers, EventBus subscribers and isUnverifiableExecError users. All but these were cleared as safe.

@puzpuzpuz

puzpuzpuz commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Verdict clarification

The previous comment's verdict ("request changes (minor)") was unclear. It should be request changes. The items to fix are listed below by their #ID and title in the Issues table.

Blocking: regressions against main

  • 1 Headless run drops committed writes (src/utils/notebooks/notebookHeadlessRun.ts:214-224)
    • When a session ends without a reload, every headless claim is aborted and nothing is committed. That includes statements the server already committed, such as the first INSERT of a multi-statement cell.
    • The cell keeps its previous "success". An agent or user can then re-run writes that already happened.
    • On main the real outcome was recorded.
    • Fix: in the beforeEnd hook, cancel only claims that haven't launched yet. Let launched runs commit their completed statements, with the in-flight one marked sessionEnded.
  • 2 Chart spins forever after SQL edit (src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts:1296-1302)
    • The early return keeps a frame from the old SQL and never sets settledKey, so the chart spins with no error and no Retry.
    • On main an error frame was shown.
    • Fix: notExecuted && current != null && resultMatchesQueries(current, queries).
  • 4 "Sign in again" clears other tabs' SSO resume (src/providers/AuthProvider.tsx:583-604)
    • logout({ clearSSOSession: true }) deletes the SSO_SESSION_ACTIVE flag in localStorage, which all tabs share.
    • Other healthy SSO tabs that reload during that window land on Login.
    • Fix: drop clearSSOSession: true from signInAgain.

To fix in this PR

The PR introduces both of these, but neither is a regression.

  • 3 CSV duplicate-drop guard is overwritten (src/scenes/Import/ImportCSVFiles/dropbox.tsx:65-77 vs :98-100)
    • The guard this PR adds is reset by the [files] effect, so the same file can still be queued twice and imported twice.
    • Fix: keep pending names in a separate ref that the effect doesn't reset, lifted into ImportCSVFiles so it survives the DropBox remount.
  • 7 Schema fallback query sent after logout (src/scenes/Schema/index.tsx:242-267)
    • A session-ended rejection triggers the information_schema.columns() fallback after logout, sent without a bearer.
    • Fix: if (isSessionEnded(error)) return in the fetchColumns and fetchTables catch blocks.

Non-blocking follow-ups

These can be addressed separately:

  • 5, 8, 9, 10, 13: pre-existing behaviour that the new contracts expose
  • 6, 11: edge cases of deliberate design choices
  • 12: Toast wrapper theme colours
  • 14–18: recovery-screen labels, button variants, contrast and screen-reader announcements
  • 19: CI secret hardening
  • 20–31: code structure
  • 32–35: missing tests

After a failed SSO renewal, the recovery view's Retry did nothing for 5
to 30 seconds: the backoff between failed renewals refused it at once,
without a request, and the view looked unchanged. While a renewal that
had timed out was still waiting for the identity provider, for up to 50
seconds, Retry was refused the same way. The view still said "Retry to
renew it".

- Retry skips the backoff between failed renewals, which protects the
  identity provider from background requests. The throttle after a
  successful renewal still applies.
- If a renewal is still in flight, Retry waits for its answer instead of
  starting another, which could spend a refresh token the first has
  rotated. It shows "Reconnecting…" meanwhile.
- The recovery view no longer asks to wait before retrying. The renewal
  warning says that queries retry the renewal after a short wait. They
  keep the backoff, so that a script doesn't renew once per statement.
A refresh round with a statement that was not executed, e.g. after a
failed SSO renewal, kept any frame the cell held, and didn't settle.
After an edit, that frame was for the old SQL, which the chart doesn't
draw. The cell showed "Drawing…" with no error until a later round
reached the server.

Keep the frame only if the chart draws it for the current statements,
using the same test as DrawCanvas. Otherwise show the failure, as for a
chart with no frame, and still keep the saved snapshot.
A failed settings refresh unmounts the console, and Retry mounts it
again without ending the tab's login. If another tab had logged out
meanwhile, removing the shared password credentials, Login was shown
while this tab still counted as signed in. The next sign-in then kept
that session: requests of the previous login could still complete into
it, and none of the session-end cleanup ran, so its last result stayed.

End the session before showing Login in that case, and explain the
interrupted requests as logout does. Stored credentials and the server
session are left alone.
- An agent's run on a closed notebook was dropped whole when the session
  ended, so the cell kept its previous run on record, even for writes
  that had already committed, and they could be repeated. Only a run
  that has not launched is cancelled now. A launched run records the
  statements that completed, and the one in flight as interrupted, with
  its outcome unverified. Its remaining statements are not sent without
  the login's credentials, and are recorded as not executed. A saved
  result says what the end of the session did to the query, instead of
  asking to sign in again.
- A chart whose frame for its SQL had no rows to draw, e.g. the error of
  a run kept when the cell became a chart, still showed "Drawing…"
  forever after a round that was not executed. Keep only a frame that
  the chart draws.
- A CSV file dropped again while the table check of its first drop
  waited for a token refresh could still be queued twice: any change to
  the list, and showing the list for the first time, forgot the pending
  name. ImportCSVFiles now tracks the dropped files that are not listed
  yet, and the drop box counts them as queued.
- "Sign in again" removed the SSO session flag that other tabs share, so
  another SSO tab that reloaded showed Login instead of signing in
  silently. It now ends only this tab's session.
- When the session ended, the Schema panel took its interrupted columns
  load for an unsupported function, and sent the fallback query without
  credentials. It now stops. After a failed renewal it doesn't try the
  fallback either, as the server ran no query. Answers that arrive after
  the session ended are not stored.
- The tables and columns of the previous login stayed in the store after
  a session end without reload, and fed autocomplete, the AI assistant
  and CSV import for whoever signed in next. They are now cleared with
  the last result.
@puzpuzpuz

Copy link
Copy Markdown
Contributor

Review of PR #617, level 3 (full review), round 2

Reviewed at a37f2ee1, which includes the fixes pushed after the previous review (8f9229da..a37f2ee1). The full level-3 process ran:

  • a change-surface map;
  • 13 parallel review agents, including a fresh-context adversarial pass;
  • one verification agent per behavioral finding (20 in total).

Structural, test-coverage and unchanged prior findings were checked directly against the source.

Quality gate: everything passes locally.

  • yarn typecheck, yarn lint (including the color-literal check) and yarn build pass.
  • yarn test:unit passes: 149 files, 2,976 tests.

CI at a37f2ee1:

  • gitleaks, "Check if OSS is on master" and the deploy preview pass.
  • The two e2e lanes ("Monorepo CI flow" and "Running Tests With Context Path") were still running when this review was written.
  • "Dispatch Enterprise Web Console E2E" was skipped; see bump web-console to 0.0.5 #29.

PR title and description

Status of the previous review's findings (at ee4d6317)

Issues

ID Issue Category Severity Location Description Steps to reproduce Suggested fix
1 SSO tab's logout signs other tabs out of their password login State & context architecture (also found by the Persistence and fresh-context agents) Minor in-diff: src/providers/AuthProvider.tsx:505-514 (logout); new caller signInAgain at :595-616 logout() always removes the shared localStorage REST_TOKEN and BASIC_AUTH_HEADER, even in a tab whose LOGIN_METHOD is sso. An SSO tab never writes those keys, so it deletes another tab's password login.
The PR makes the login choice tab-local: setAuthToken says "never erase another tab's password credentials", and an e2e test "preserves other tabs' password credentials". It also adds a new user-facing path into logout(): "Sign in again", from both the refresh-failed toast and the recovery screen. The existing paths are 401, invalid_grant and 403.
The password tab keeps working until it reloads, or until a Settings recovery remounts it. Then it lands on Login, and the remount also ends its session and interrupts its running requests.
main removed the keys too, but it had no tab-local login contract.
• Tab B: sign in with a password
• Tab A: sign in with SSO
• Make tab A's refresh fail and click "Sign in again"
• Reload tab B: it shows Login
Read LOGIN_METHOD before clearing it. Remove REST_TOKEN/BASIC_AUTH_HEADER only if this tab signed in with a password. Add an AuthProvider test that signInAgain keeps REST_TOKEN.
2 Headless read-only run overwrites the last result when the session ends Persistence & migrations Minor in-diff: src/utils/notebooks/notebookHeadlessRun.ts:225-239 (beforeEnd aborts only claims that haven't launched); commit at :551-617 The fix for prior #1 lets every launched headless run commit when the session ends. Read-only runs gain nothing from that:
• The client checks every completion against the session, so an in-flight SELECT always comes back as sessionEnded ("Interrupted… may have executed").
• Queued statements come back as "Not executed".
• The commit then overwrites the cell's lastRunStatus and its on-disk snapshot (persistCellSnapshot) in an unmounted notebook.
• The agent also gets UNVERIFIED_RUN_NOTE, which says a SELECT "may have committed server-side… avoid duplicate writes".
On main the launched SELECT finished and its real rows were recorded. The PR description says headless runs are "superseded first, so their aborted results don't overwrite snapshots", but that holds only for runs that haven't launched.
• Open notebook N, run a multi-statement SELECT cell, then switch to another notebook
• Ask the agent to run that cell (gated, so its statements are classified)
• While it runs, click "Sign in again"
• Sign in and open N: the cell shows an error instead of its previous rows
At launch, record claim.readOnly = !!classified && !hasWriteStatement(classified). In beforeEnd, abort when !claim.launched || claim.readOnly. Add a test that a read-only run keeps its lastRunStatus and snapshot.
3 Chart snapshot deleted on the second round that wasn't executed Persistence & migrations Minor in-diff: src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts:1307-1321 vs :1347 The comment says a round whose statements weren't executed "still leaves the snapshot". That holds only for the first round. This only applies when no rows frame is drawn: no frame at all, a frame from edited SQL, or an error/empty/truncated run-mode frame.
• Round 1 writes the error frame and returns before the snapshot logic.
• Round 2 (next poll tick, or a Retry click) finds that error frame. resultsEquivalent returns true, because it ignores requestNotSent and the renewal message never changes. With no successful results, the branch calls clearSnapshot → deleteCellSnapshot.
The notExecuted guard sits below that branch.
On main the snapshot was deleted in round 1, so this is an incomplete fix rather than a regression. The engine tests miss it because the not-executed cases use autoRefresh: false.
• A chart cell with auto-refresh and no rows frame drawn (e.g. switched from a run cell whose frame was over 2 MB and truncated, or SQL edited back to text classified earlier)
• Make the IdP refresh fail and let the token expire
• Round 1 shows the renewal error; on the next tick the IndexedDB notebook_results row is deleted
In the resultsEquivalent branch, add if (notExecuted) return right after deriveChartSlotErrors(entry) (:1318). Add a polling test with two not-executed rounds that asserts deleteCellSnapshot is not called.
4 Re-runs fail instantly while a hung refresh holds the slot Async, timers & cancellation Minor in-diff: src/utils/questdb/tokenRefresh.ts:110-118, 125, 184-202; copy in src/modules/OAuth2/views/refreshFailedWarning.tsx:38 Prior #6, partially fixed.
When the token endpoint hangs, the failure is reported at the 10 s soft deadline, but the slot is held until the 60 s hard timer. Until then:
• start() returns early;
• refreshBeforeRequest awaits a soft deadline that has already settled;
• once the token has expired, ensureUsableToken throws.
So every re-run in the editor, notebooks or AI chat fails instantly, with no new token request, for about 30–50 s.
8f9229d fixed only the startup Retry (prepareRenewalRetry). It also changed the toast to "Run them again after a short wait to retry the renewal", which this path contradicts.
No wrong data, and nothing is sent.
• SSO token close to expiry; make the IdP token endpoint stall
• After the toast appears and the token expires, run a query every 5 s: each fails instantly
• No refresh_token POST appears until about 60 s after the refresh started
For non-background requests where the slot is past its soft deadline, the refresh has failed and the token has expired, await hardDeadline (abortable, reported through onRefreshWait) before ensureUsableToken, as prepareRenewalRetry does. Alternatively, tell the user how long to wait.
5 Recovery Retry shows only "Reconnecting…" for up to ~60 s Async, timers & cancellation (also found by the Accessibility & UX agent) Minor in-diff: src/utils/questdb/client.ts:137-142 (prepareRenewalRetry); src/providers/QuestProvider/AuthCheckRecovery.tsx:53-58, 106-110. Introduced by 8f9229d Retry on the "Could not renew your SSO session" screen now waits for the held refresh slot's hard deadline, up to 60 s after the refresh started. SELECT 1 can then wait another 10 s for a new refresh.
Throughout, the only feedback is "Reconnecting…" and a busy Retry button. Nothing says the console is waiting for the identity provider, or that "Sign in again" (still usable) is the faster way out.
A test pins the wait as intended; the missing explanation is the defect.
• Reach the recovery screen with an expired token while the IdP stalls (it appears about 10 s in)
• Press Retry: "Reconnecting…" stays for about 50–60 s
While tokenRefresh.hardDeadline is pending, show something like "Waiting for your identity provider (up to N s)… You can also sign in again." Also pass retryLabel (see #24).
6 Refresh-failed toast stacked over the recovery screen Accessibility & UX (also found by the Styling & theming agent) Minor in-diff: src/index.tsx:96 (ToastContainer moved under AuthProvider); src/providers/AuthProvider.tsx:278-292, 305, 394; AuthCheckRecovery.tsx:99-114 ToastContainer now mounts next to QuestProvider, so toasts appear over the startup-check and recovery screens. When renewal fails, MSG_AUTH_REFRESH_FAILED (or the retained-payload remount at :394) opens the persistent refresh-failed toast while the recovery screen shows the same failure. The user then gets:
• two role="alert" regions;
• two "Sign in again" buttons;
• toast text ("Run them again after a short wait…") about queries that can't be run from that screen.
A test name ("independently of the toast") suggests the overlap is known, but the wording wasn't adjusted.
Toasts still don't show on Login: the container is a child of the ready view.
• SSO session; the IdP refresh fails and the toast appears
• A DDL triggers a settings refresh while the server restarts, which opens the Settings error view
• After the token expires, click Retry: the recovery screen and the toast show together
Dismiss or suppress REFRESH_FAILED_TOAST_ID while authCheckError?.renewalUnavailable is shown (later failures raise it again), or make the toast's expired-token copy context-neutral.
7 Metrics and telemetry stay off after a renewal hiccup Cross-context caller impact (found by the fresh-context agent) Minor in-diff: src/store/Telemetry/epics.ts:88-94, src/utils/telemetryQueries.ts:7-10. Visible effect out-of-diff, in scenes/Editor/Metrics The startup config read uses refreshAuth: false. That throws AuthRefreshUnavailableError whenever a refresh is pending, has failed, or the token is within 30 s of expiry. The epic swallows the error (catchError(() => EMPTY)), and START is dispatched once per QuestProvider mount (QuestProvider/index.tsx:158), so the config is never loaded.
Effects, until reload:
• The Metrics panel says "Metrics unavailable – Enable Telemetry… set telemetry.enabled=true", which is false and gives the user nothing to do.
• Console event tracking stays off.
Triggers:
• a Settings-recovery remount with a token near expiry and a failing IdP (the auth check passes on the still-valid bearer after its renewal fails);
• IdP token lifetimes of 30 s or less.
On main the config read used a fixed header with no gating.
• SSO; the token is within 30 s of expiry and the IdP returns 503
• Trigger Settings recovery and click Retry
• Open Metrics: it says "Enable Telemetry"
Use { background: true } (refresh allowed) for the one-time startup config read and keep refreshAuth: false for the hourly loop. Alternatively, retry getServerInfo on isAuthRefreshUnavailable with a bounded timer that sessionEnded$ stops. Add an epic test.
8 Settings recovery silently switches the password identity State & context architecture (also found by the React and Security agents) Minor in-diff: src/providers/AuthProvider.tsx:451-472 (uiAuthLogin); src/modules/OAuth2/authSessionState.ts:44-47; src/providers/QuestProvider/index.tsx:80-83 2ef62f9 ends the tab's login on a Settings-recovery remount when another tab has removed the shared password credentials. It doesn't handle another tab having replaced them. In that case:
• markNonSSOAuthenticated() doesn't bump sessionId, because the tab is already authenticated;
• the view goes to ready;
• the remounted QuestProvider captures the other user's Authorization.
No session end runs, so the previous user's result, schema and notifications stay on screen while queries and writes run as the other user.
This existed on main, where QuestProvider read localStorage on every mount. The PR's tab-local contract covers only half the case.
• Tab 1: sign in with a password as alice and run a query
• Tab 2: log out, then sign in as bob
• Tab 1: run a DDL while /settings briefly fails, which opens the Settings error view; click Retry
• Tab 1 still shows alice's results, but select current_user returns bob
At login, store the tab's own credential (in sessionStorage next to LOGIN_METHOD, or in authSessionState). In uiAuthLogin, if the tab is authenticated and the stored credential differs, call endSession(...) and show Login, as the removal branch does.
9 Failed table check imports with new-table defaults Query execution & data integrity Minor in-diff: src/scenes/Import/ImportCSVFiles/index.tsx:124-130, 155-213, 240-263, 362-399 Prior #5, still present.
A failed /chk keeps the file with status FAILED and new-table defaults: exists:false, schema:[], partitionBy:"NONE", and no timestamp or TTL. This happens even when Redux tables already holds the table.
handleVisible and rename later update only status. So after renewal recovers, the row shows the normal "Table already exists" badge while still carrying new-table defaults, with editing unlocked.
Append is safe, because the server keeps the table's structure. Overwrite recreates the table unpartitioned and without its designated timestamp.
On main a failed check rejected the whole drop.
• Make one /chk fail (renewal failure)
• Drop trades.csv for an existing table partitioned by DAY: the row says "Could not check table name"
• Switch away and back after recovery: it now says "Table already exists"
• Choose Overwrite and upload
Whenever a check returns EXISTS (initial check, handleVisible, rename), recompute exists, schema, partitionBy, timestamp and TTL. Alternatively, block Overwrite while the file's fields came from a FAILED check.
10 Existing table imported with an empty, locked schema Query execution & data integrity Minor in-diff: src/scenes/Import/ImportCSVFiles/index.tsx:132-153 (getExistingTableSchema swallows errors) When /chk says EXISTS but SHOW COLUMNS then fails, the file is listed as an existing table with schema:[] and an empty, locked schema dialog. No failure is shown. Possible causes: renewal failing or the token expiring between the two calls, a transient 5xx, or a missing column permission.
The upload sends schema=[]. In Overwrite mode the table is recreated with column types inferred from the CSV, so for example SYMBOL or INT columns can change type.
On main the failure rejected the drop. A test pins the new listing (index.test.tsx, "…whose schema cannot be read"), but nothing covers the upload.
• Drop trades.csv for an existing table
• Make /chk succeed and SHOW COLUMNS FROM 'trades' fail
• The row looks normal, with an empty locked schema
• Overwrite: the types are inferred
Flag schemaUnavailable on the row and show it. Re-fetch showColumns before an Overwrite upload, or block Overwrite while the schema is unknown.
11 Upload blocked by renewal shows a generic "Upload error" Accessibility & UX Minor in-diff: src/scenes/Import/ImportCSVFiles/index.tsx:345-353; thrown at client.ts:953-954 With renewal failed and the token expired, uploadCSVFile rejects with AuthRefreshUnavailableError before anything is sent. The catch discards that error and shows "Upload error", the same text as a mid-stream failure whose outcome is unknown.
The user can't tell that nothing was imported and that retrying is safe. Once the refresh toast is closed, nothing else explains it.
• SSO with a failing IdP and an expired token; close the toast
• Import → Upload: the row says "Upload error"
Map isAuthRefreshUnavailable(err) to that error's message ("…the request was not executed…") in the row's error text.
12 Paste after a dialog shows a false "duplicate" error React correctness & hooks Minor in-diff file: src/scenes/Import/ImportCSVFiles/dropbox.tsx:82-102. These effects are unchanged, but they now interact with the new unlistedNames() (:70-78) handlePaste is a new function on every render. The [dialogOpen] effect removes the current render's handler, which was never registered, and adds another one when the dialog closes. So every open/close of the rename or schema dialog adds a live window paste listener, and all but the first one leak after unmount.
On main one paste queued the file once per listener. Now the first listener queues it, and the others find it in unlistedNames() and show "File already added to queue … Change target table name" for a file that was added once.
The leak predates this PR; the symptom is new. Nothing is duplicated.
• Drop a.csv
• Open and close the rename dialog
• Paste b.csv (Ctrl+V): b.csv is listed once, and the red duplicate error appears
Use one effect keyed on dialogOpen that adds and removes the same function and calls the latest handler through a ref. Add a paste test that toggles a dialog.
13 Catalog reloaded 2–4 times after renewal recovery Performance & rendering at scale Minor in-diff: src/scenes/Schema/index.tsx:174-178 (recordRenewalFailure), with the unchanged handler at :346-352 and client.ts:677 The PR now sets errorRef on renewal failures. The MSG_CONNECTION_OK handler reloads tables and columns on every OK event while errorRef is set. errorRef is cleared only after several awaits, but the client publishes OK synchronously, before resolving.
So the reload's own response, and any other response during that round-trip, starts another full reload. The result is 2–4 rounds of tables() + questdb_columns() (plus views) instead of one, each re-rendering the schema consumers and completion providers.
The same pattern exists on main after MSG_CONNECTION_ERROR; the PR adds a new way in. The tests hide it because their mocks don't publish OK.
• Renewal fails, and Schema shows the error
• Restore the IdP and run a query: tables() and questdb_columns() are each sent at least twice
Clear errorRef in the handler before reloading (a failure sets it again), or add an in-flight guard.
14 Variables "Apply" fails silently on a renewal failure Async, timers & cancellation Minor out-of-diff: src/scenes/Editor/Notebook/globals/VariablesPopover.tsx:399-447. Contract 2.5c: validateQuery can now wait for a renewal and reject with AuthRefreshUnavailableError / SessionEndedError handleApply has try/finally without a catch. With renewal failed, Apply shows "Validating…" (for up to 10 s while a renewal retry runs), then goes back to "Apply" with no message and an unhandled rejection. The popover stays open.
On main network errors and 401s already failed silently the same way; the PR adds a common new trigger.
• SSO, IdP returning 5xx, token expired
• Open the Variables popover, edit a value, click Apply
Catch in handleApply. Show isAuthFailure(e) ? e.message : "Could not validate variables", and ignore aborts.
15 Unmounted Schema panels keep querying after logout State & context architecture Minor in-diff file, unchanged effects: src/scenes/Schema/index.tsx:335-382. Contract: session end stops the previous console's work Schema never removes its eventBus subscriptions or its window focus listener. Every session end without a reload ("Sign in again", 401/403/invalid_grant, Settings recovery) leaves a dead instance behind:
• On Login, each refocus (at least 10 s apart) sends tables(), questdb_columns() and the columns() fallback without credentials, which only produces 401 noise.
• After a password re-login in the same tab, each dead instance repeats the catalog loads on focus and MSG_QUERY_SCHEMA. The number of dead instances grows with every cycle.
The session check prevents data from crossing between logins. This predates the PR; the PR adds a common no-reload path.
• Password login
• "Sign in again" or a 401 logout
• Refocus the window on Login: 3 unauthenticated /exec requests
• Sign in again and refocus after 10 s: the catalog requests are duplicated
Return cleanups from both effects: unsubscribe the named handlers and remove the focus listener.
16 MCP pairing carries over to the next login State & context architecture (security) Minor out-of-diff: src/providers/MCPBridgeProvider/index.tsx:398-414, src/utils/mcp/mcpBridgeStorage.ts:42-61. Contract: session end clears what the previous login set up A consented MCP pair is kept in sessionStorage and reconnected silently on mount. No session-end path clears it.
After "Sign in again" or any logout, the next login in that tab, possibly a different user, automatically reconnects the previous user's external agent. Its tool calls then run with the new user's credentials.
This predates the PR; the PR adds session-end listeners, but not this one.
• User A pairs an agent and consents
• A uses "Sign in again" or logs out
• B signs in in the same tab: the bridge reconnects without asking
authSessionState.onEnd(() => clearPendingPair()), or drop consented so the next login is asked again. Fine as a follow-up together with #603.
17 Downloads fail after a 401 outside /exec Cross-context caller impact Minor out-of-diff: src/utils/downloadQueryResult.ts:8-49. Contract 2.5d#10: a 401 now destroys the cookie session, which is reopened only on the next /exec A 401 from validate, chk, imp or settings only records _lostCookieSession (client.ts:398-405). The next SQL request reopens the cookie session.
Downloads are cookie-only iframe GETs to exp. A download between a validation 401 (from typing in the editor) and the next query therefore fails with "An error occurred while downloading the file…". Running any query fixes it.
• EE + SSO with the server change
• Get the bearer rejected while only typing, so validation gets a 401
• Click Download: it fails
• Run a query: downloads work again
Expose reopenCookieSessionIfLost(), a background select 1 sent through fetchExec, and await it in downloadQueryResult before setting iframe.src.
18 Open and headless notebook runs disagree at session end Test review & coverage (persistence) Minor in-diff: src/scenes/Editor/Notebook/useCellExecution.ts:1035-1052 vs notebookHeadlessRun.ts:225-239; test useCellExecution.test.ts:24-89 In an open notebook, session end supersedes every run. A cell whose INSERT committed keeps the previous run's result and snapshot. This matches main, and the Login text and the agent's unverified note warn about it.
Headless runs now record their completed statements because "an agent could repeat writes that already committed". The same reasoning applies to open notebooks, yet the new live-notebook test pins the dropped INSERT.
Not a regression, but the difference is unexplained.
• Open a notebook and run INSERT …; INSERT … where the second is slow
• Click "Sign in again" and sign in: the cell shows the previous success
Either record launched write runs in open notebooks as headless runs do, or document the difference and change the test example to a SELECT.
19 Outcome of an upload from an ended session is never shown Query execution & data integrity Minor in-diff: client.ts:997-1003; AuthProvider.tsx:198-206; unfinishedWritesWarning.tsx:14-20 Prior #10, still present.
An upload that keeps running after its session ends rejects with SessionEndedError even on HTTP 200, so the UploadResult is discarded.
The toast is dismissed as soon as the count reaches 0. The new "finished sending its requests" text is therefore visible only during the exit animation, and the user never learns whether the import succeeded.
• Start a large upload
• Click "Sign in again" and sign in with a password
• When the upload finishes, the toast disappears without a result
Record each write's outcome (table, rows or errors, or "unknown"), and show it in a final toast that isn't dismissed at count 0.
20 401 after a resend shows up as a SQL error Query execution & data integrity Minor in-diff: client.ts:725-750 Prior #13, still present.
When a resend's 401 isn't published, because the bearer rotated again during the resend, the rejection is a bare {status:401, error:"Unauthorized"}, with no position, type or requestNotSent.
Monaco records it as a SQL error marker, and the notebook refresh shows "Unauthorized". It's a narrow race.
• The server rejects freshly rotated bearers
• Q1 resends with bearer B while a concurrent 401 rotates the bearer to C
Add position:-1, query, type:ERROR, ...sessionFailureOf(execution), or reject as AuthRefreshUnavailableError.
21 Catalog panels blame the network for a renewal failure Cross-context caller impact Minor out-of-diff: src/scenes/QueryActivity/index.tsx:187, src/scenes/Schema/TableDetailsDrawer/index.tsx:768. Contract: authRefreshUnavailable is not a connection failure Prior #8, still present.
A renewal failure is recorded as a generic failure, so both panels say "The console cannot reach the server". At the same time the footer says Connected, and the toast says the SSO session couldn't be renewed.
• SSO with the IdP returning 5xx; wait for the token to expire
• Open Query Activity or Table details
Carry an authRefreshUnavailable reason through useCatalogSource and show the renewal message.
22 Fast browser clock causes a refresh every 30 s Performance & rendering at scale Minor in-diff: src/modules/OAuth2/utils.ts:109-125, tokenRefresh.ts:160-175 Prior #11, unchanged; this is a deliberate design choice.
In ID-token mode the expiry is the absolute exp compared with the browser clock. A clock that is ahead by about one token lifetime makes every token look expired. Consequences:
• a refresh about every 30 s while pollers run;
• one transient failure blocks all queries for the backoff period;
• telemetry never sends.
• ID-token mode with the OS clock 1 h ahead
• Leave Query Activity open: a refresh_token POST appears about every 30 s
Estimate the skew from the token response's Date header, or refuse a locally "expired" bearer only after the server has actually returned 401 for it.
23 Persistent auth toasts become unreadable after a theme switch Styling & theming Minor out-of-diff root cause: src/components/Toast/index.tsx:67-128, which bakes getThemeColor() values into style. Consumers: refreshFailedWarning.tsx, unfinishedWritesWarning.tsx Prior #12, still present.
These are the only autoClose:false toasts. Their Text follows the live theme, but the background keeps the theme from when the toast was created.
After a switch (in the app, from another tab, or from the OS with the default "system" setting), text and background are about 1:1: #1c2029 on #1d1e24, or #f8f8f2 on #f4f5f7. The "Could not renew" and "keep this tab open" warnings go blank.
• Dark theme; trigger a refresh failure
• Switch to light: the toast text disappears
Use var(--qdb-color-…) in the Toast wrapper's style, or call toast.update on MSG_THEME_CHANGED.
24 Recovery screen: wrong button label and two primary buttons Accessibility & UX (also found by the Styling & theming agent) Minor in-diff: AuthCheckRecovery.tsx:101-114; ConnectionErrorView.tsx:24, 64-72 Prior #14 (the label part) and #15, still present.
Under "Could not renew your SSO session", the button says "Retry connection" and the status says "Renewal failed. Retry connection, or sign in again." retryLabel is never passed.
Retry and the session-ending "Sign in again" are both primary, which goes against Button/README.md ("single main action").
• Trigger the renewal recovery screen Pass retryLabel={renewalUnavailable ? "Retry renewal" : undefined}, and add variant="secondary" to "Sign in again".
25 Low-contrast text on the recovery screen (light theme) Styling & theming Minor in-diff: ConnectionErrorView.tsx:49-60, on CenteredLayout's authBackdrop Prior #16, still present.
On #c7cbd2:
• statusDanger #ce1717 is about 3.4:1;
• contentSecondary #505968 is about 4.3:1.
Both are below WCAG AA's 4.5:1, and this is the main failure text on the screen.
• Light theme; block /exec so the startup check fails Use darker tokens (or contentPrimary text with only a danger-coloured icon), or lighten authBackdrop.
26 New status text isn't announced to screen readers Accessibility & UX Minor in-diff: login.tsx:399, 460-472; AuthProvider.tsx:201-205; QuestProvider/index.tsx:181-196 Prior #17 and #18, still present.
• The unfinished-writes notice mounts already filled in with role="status". Focus goes to the error banner, whose aria-describedby covers only the error, so "keep this tab open" isn't read.
• The console toast re-reads its whole paragraph every time the count changes.
• "Checking your session…" is a new role="status" that mounts with its text already in place after 500 ms, which screen readers often don't announce.
• Start an upload, click "Sign in again", and use NVDA or VoiceOver: only the banner is read Add the notice's id to the banner's aria-describedby. Give the toast role:"status", or announce only the count. Render an empty role="status" from the start and fill it in after 500 ms.
27 "Sign in again" banner title reads like a control Accessibility & UX Minor in-diff: AuthProvider.tsx:610-615 → login.tsx:393-412 After the user chooses "Sign in again", Login focuses a red error banner whose accessible name is "Sign in again". A screen reader says "Sign in again, group — Your previous session has ended. Please sign in again."
The title is a verb phrase and repeats the message, and a step the user chose is styled as a failure.
• Click "Sign in again" in the toast; Login shows the red banner, with focus on it Use the title "Session ended" and the message "Sign in to continue."
28 Enterprise dispatch secret and private CI reachable from PR branches Browser compatibility & security Minor in-diff: .github/workflows/enterprise_e2e.yml:3-31; e2e/README.md Prior #19, still present.
pull_request runs the workflow file from the PR branch, and only forks are excluded. A same-repo branch can drop the if: or exfiltrate ENT_DISPATCH_PAT, whose "Build (Read & execute)" scope is not limited to one pipeline.
Even unmodified, the workflow has the private lane install and build the branch's package.json (and every dependency bump on main) next to EE CI credentials.
The Netlify token is already exposed the same way; the new part is the reach into private Enterprise CI.
• A user with push access opens a branch PR that edits the workflow, or a package.json script Put the secret in a GitHub Environment with required reviewers. Use a service identity that can only queue that one pipeline. In the private lane, build the UI in a job with no EE secrets or source.
29 The real-server test of the server change hasn't run Test review & coverage Minor in-diff: .github/workflows/enterprise_e2e.yml:21-27; e2e/tests/enterprise/oidc.spec.js:272 Only the Enterprise lane tests the server change against a real server: a 401 despite a valid cookie, cookie deletion, the resend reopening the session, and a cookie-only export.
On a37f2ee1, "Dispatch Enterprise Web Console E2E" was skipped because ENTERPRISE_E2E_ENABLED isn't set. Even when enabled, its result is a separate status that isn't required. All required auth e2e tests stub /exec.
So the PR can merge green without the server-change behaviour ever being exercised.
• Open the checks on a37f2ee1: the dispatch job is skipped and there is no enterprise-e2e-web-console status Before merging, run the private lane on the head SHA and link the green enterprise-e2e-web-console status. Make it a required check once it is enabled.
30 Duplicated logic and copy Code structure, readability & types Minor in-diff: Monaco/index.tsx:1609-1613, 2220-2224, runDetachedQuery.tsx:145-147, notebookUtils.ts:301-303; login.tsx:461-472 vs unfinishedWritesWarning.tsx:24-32; executeSingleRaw.ts:61-65 vs tools/runQuery.ts:118-120; ImportCSVFiles/index.tsx:124-130 vs :382-387 Prior #23–25 and #27, still present.
• isSessionEnded ? sessionEndedQueryMessage : error is copied 4 times, and the fallbacks have drifted apart.
• The unfinished-writes headline and body are duplicated and already differ.
• Both query adapters turn resolved ERROR results into rejections, although the real client never resolves one; the code exists only for test doubles.
• checkTableName is re-implemented inline in the rename path.
n/a Add queryFailureMessage(error, fallback) to session.ts. Share an unfinished-writes helper. Make the mocks reject and drop the normalizers. Call checkTableName in the rename path.
31 Loose types force casts Code structure, readability & types Minor in-diff: session.ts:38-50, types.ts:111-124, client.ts:725, Schema/index.tsx:176-177, AuthCheckRecovery.tsx:71, ImportCSVFiles/types.ts:7 Prior #21, #22 and #25, still present.
• isSessionEnded and isAuthRefreshUnavailable aren't type guards, so consumers cast.
• The same failure has two shapes: an Error with .message, or an object with .error.
• errorPayload is an untyped Record, which is how #20's missing fields went unnoticed.
• ErrorResult is a bag of independent optional flags.
• connectionError is set but never read in production.
• ProcessedFile.status is still string despite TableNameCheck.
n/a Make the helpers type predicates, type errorPayload as ErrorResult, and consider a failure discriminant. Drop or use connectionError. Type status as FileCheckStatus | TableNameCheck.
32 Misleading names, unused props, leftover string literals Code structure, readability & types Minor in-diff: handleUnauthorized.ts:12, types.ts:376-377, ConnectionErrorView.tsx:18-36, useChartQuery.ts:17, Monaco/index.tsx:2294-2295, useCellExecution.ts:116, 876, 986 Prior #26, #28 and #30, still present.
• handleSSOUnauthorized also handles password sessions.
• refreshAuth's JSDoc doesn't mention that a 401 then never logs out, and it blurs the option with background.
• ConnectionErrorView's retryLabel and preloadedImages are never passed, and SettingsProvider keeps its own separate error view.
• Raw "Cancelled by user" literals remain next to CANCELLED_BY_USER, so the notebook marker list now depends on two separately maintained strings.
n/a Rename or document. Drop the unused props, or move the Settings error view onto this component. Use the constant and isCancelledByUser everywhere.
33 File growth, redundant re-checks, nested ternaries Code structure, readability & types Minor in-diff: client.ts (716 → 1,146 lines; sendQuery takes 6 positional parameters and is about 95 lines); client.ts:794-795, 922-923, 953-954, 1042-1043; AuthProvider.tsx (468 → 679 lines; "sso"/"password" at :234, 396, 399, 457, 657); tokenRefresh.ts:27-30; QuestProvider/index.tsx:181-196; AuthCheckRecovery.tsx:106-110 Prior #20 and #31, still present.
• prepareRequest already runs ensureRequestAllowed, yet four callers check again, in three different ways.
• Two adjacent AbortSignal parameters with different meanings can be swapped without any compile error.
• The login-method strings and the "mark authenticated + set LOGIN_METHOD + ready" sequence are repeated.
• JSX uses nested ternaries.
n/a Extract retryAfterUnauthorized() and pass an options object. Use {deadlineSignal, requestSignal}. Add a LoginMethod type with helpers and a completePasswordLogin() helper. Use early returns.
34 Missing tests for new behaviour Test review & coverage Minor in-diff: toolbar.tsx:439-487, Result/index.tsx:208-211, Monaco/index.tsx:1590, 1875, 2209, src/index.tsx:76 Prior #32–35, still present apart from the helper test. There is no test for:
• handleSaveSettings: the best-effort telemetry .catch and the session checks;
• the grid page's isSessionEnded early return;
• auth failures leaving no editor marker;
• "Stopped before running any query".
The real store wiring of clearLoginDataOnSessionEnd is exercised only after the toolbar logout, which reloads, so deleting index.tsx:76 would still pass.
#3 and #13 also slipped past the tests: the engine's not-executed tests use autoRefresh:false, and the Schema mocks never publish MSG_CONNECTION_OK.
n/a Add:
• a toolbar renderer test;
• a Result test with a queryRaw that rejects {sessionEnded:true};
• auth-spec assertions on getErrorMarker() and the summary text;
• a 401 or "Sign in again" → password re-login e2e that asserts no grid-row and an empty schema;
• a polling engine test;
• a Schema mock that publishes OK.

False positives

  • Query execution & data integrity. Claim: a non-OK /chk response with a JSON body bypasses FAILED, so the row gets no badge and the upload stays enabled.
    QuestDB's /chk handler always answers 200 with JSON. Every non-200 response comes from the reject processor as text/plain, so response.json() throws and the PR's catch maps it to FAILED. Only a non-QuestDB proxy that returns JSON errors could reach this path. Optional hardening: throw on !response.ok.
  • Cross-context caller impact. Claim: a Query Activity cancel interrupted by the session end shows "Your session has ended. Please sign in again." after re-login, because react-toastify queues toasts until a container mounts.
    endSession aborts the CANCEL request synchronously, and its rejection runs in microtasks. At that point react-toastify still has the old container registered, because unregistering happens in a deferred passive cleanup plus a setTimeout. So the toast is sent to the unmounted container and dropped, not queued. For consistency, handleConfirmCancel could still skip isSessionEnded errors.
  • Fresh-context adversarial. Claim: a 401 on startServerSession isn't recorded, so downloads stay broken for the whole session.
    The startup check sends SELECT 1 through the client with the same freshly installed bearer. If the server rejected that bearer for startServerSession, it rejects it for the check too. That records the loss and resends with session=true, which the "Auth - OIDC bearer rejected at startup" test covers.
  • Styling & theming. Claim: outline:none on the new Heading and Title may lose to the global *:focus-visible rule and show a focus ring on programmatic focus.
    The two rules have equal specificity, and styled-components' registration order decides between them. About 20 existing components rely on the same pattern. The ring was not shown to actually appear, and this isn't specific to this PR.

Summary

- Ending the session of an SSO tab, by logging out, signing in again or
  after a 401, removed the password credentials that tabs share, so
  another tab signed in with a password showed Login once it reloaded.
  Only a tab signed in with a password removes them now. An SSO tab also
  keeps its login method, so that reloading it resumes SSO or shows
  Login, instead of signing in with another tab's password.
- Logging out from the toolbar reloads the page at once, which cancelled
  running statements and uploads being sent without asking, although a
  cancelled CSV upload can leave a partially imported table. It now asks
  first, and keeps the session if declined.
- Logging out from the toolbar also cleared the last result, tables and
  columns before the reload, blanking the console for a moment. The
  reload discards them anyway, so they are left to it.
- When the session ended, an agent's read-only run on a closed notebook
  recorded its reads as interrupted or not executed, replacing the
  previous run and its saved rows. A read-only run is now cancelled like
  one that has not launched, so the previous run stays on record. A run
  that writes still records what it did.
- Once the token had expired, a statement run again while a timed-out
  renewal still waited for the identity provider, for up to 50 seconds,
  failed at once without a new renewal, although the renewal warning
  says that running it again after a short wait retries the renewal.
  After the backoff, a statement the user runs now waits for that
  renewal to be answered or given up, and then renews. Background reads,
  and statements run during the backoff, still fail at once.
- The one-off telemetry config read at startup never renewed the
  session, so it failed near expiry or after a failed renewal, e.g. at
  every login with a token that lives under 30 seconds. Metrics then
  asked to enable telemetry, and console events were not tracked, until
  the next login. The read may now renew the session, like the console's
  other reads.
- If the request that opens the cookie session at SSO sign-in was
  rejected or could not be sent, downloads could fail for the whole
  login: only a later 401 made a request ask for a new session. The next
  SQL request now asks for one in that case, and a network error there
  no longer escapes as an unhandled rejection.
- A chart without a frame it could draw deleted its saved snapshot on
  the second round in a row that was not executed: that round matched
  the failure the first one showed, and an unchanged frame without rows
  cleared the snapshot. The snapshot now stays until a round reaches the
  server.
@puzpuzpuz

puzpuzpuz commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Review of PR #617, level 3 (full review), round 3

Edited: the verdict changed from "request changes (small)" to approve with follow-ups. None of the findings meets the bar for blocking. See the Summary.

Reviewed at 93d55dd2, which includes "fix: close more gaps around logout, session end and SSO renewal" on top of round 2 (a37f2ee1). The full level-3 process ran:

  • a change-surface map of the whole PR, plus the callsite inventory;
  • 13 parallel review agents, including a fresh-context adversarial pass;
  • one verification agent per new behavioural finding (14 in total).

Style, structure and test-gap findings were checked directly against the source. Round-2 items were re-checked at HEAD by the domain agents.

Quality gate: everything passes locally.

  • yarn typecheck, yarn lint (including the colour-literal check) and yarn build pass.
  • yarn test:unit passes: 149 files, 2,996 tests, which matches the PR description.

CI at 93d55dd2:

PR title and description

  • The title follows the conventions.
  • The description is up to date now: f21c6570, the validation numbers, and the store reset that clears result, tables and columns.
  • It documents that only a password tab removes the shared password credentials when it logs out. clean web console #7 is the shared-machine consequence of that.

Status of round-2 findings

The core client, refresh and session-fence machinery held up again under every path traced:

  • no SQL statement can be sent twice;
  • requestNotSent is correct on every rejection branch except the bare resend 401 (releasable web console #9);
  • completions are fenced against the session that started them;
  • the new stalled-renewal wait is bounded, cancellable, stops at session end, and can't start a second refresh.

Issues

New in this round

ID Issue Category Severity Location Description Steps to reproduce Suggested fix
1 Stalled-renewal wait skips uploads, saves and checks Async, timers & cancellation (also Query execution and Cross-context agents) Minor in-diff: src/utils/questdb/client.ts:405-410 (prepareRequest), :489; tokenRefresh.ts:131-139 93d55dd makes a user statement, re-run after the backoff, wait for the hung renewal and then renew. Only sendQuery opts in, so other requests still fail at once with AuthRefreshUnavailableError until the 60 s hard deadline frees the slot:
• requests through prepareRequest: CSV upload, /chk, settings save, validation;
• user-started reads flagged background: quick-vis Draw, chart Retry, grid paging.
The window is about 30 s when the refresh started before expiry, and up to about 45 s when it started after (e.g. a tab waking from sleep).
Effects:
• Upload shows the generic "Upload error".
• A dropped file is listed as "Could not check table name" with new-table defaults, which makes #26 (round-2 #9) easier to hit.
• An editor SELECT in the same window waits and succeeds.
It recovers by itself; nothing is lost.
• SSO; make the IdP token endpoint hang; let the token expire
• After the toast, wait about 5 s, then click Upload in Import: "Upload error" at once, and no refresh_token POST until about 60 s
• Run select 1 in the editor at the same time: it waits, then renews
Add a waitForStalledRenewal option to prepareRequest and pass true from uploadCSVFile, savePreferences and checkCSVFile. Add an "interactive" flag for user-started background reads that sets the wait without changing their logout/confirm behaviour. Add a client test for an upload during a stalled slot.
2 Retry fails instantly when the server rejected a still-valid bearer Async, timers & cancellation Minor in-diff: tokenRefresh.ts:111-117, 131-139; client.ts:532-563 stalledRenewal() requires a token that has expired by the browser clock. Suppose a refresh was started by a server 401 on a bearer that is still valid locally (revocation, or in ID-token mode a server clock ahead of the browser), and the IdP hangs for more than 10 s. Then for about 45 s every user re-run:
• is sent;
• gets a 401;
• finds the held slot's settled soft deadline;
• fails "Could not renew…" at once.
This contradicts the contract that a re-run after the backoff waits for the stalled renewal. It recovers after the hard deadline. No logout.
• SSO with a token valid for about 5 min locally; the server rejects it and the IdP hangs
• Run a statement: 401, then it waits 10 s and fails
• Re-run after 5 s: it sends, gets a 401 and fails at once; this repeats until about 60 s
Track "the server rejected this payload" in the 401 branch and accept it in place of hasUnusableToken() in stalledRenewal(). Alternatively, in the 401 branch, when pending.stalled and the backoff is over, await hardDeadline, then start a refresh and wait once before throwing.
3 Stalled wait shows a plain "Running…" for up to ~55 s Accessibility & UX (also Async and Code-structure agents) Minor in-diff: client.ts:385-392; no lifecycle passed at Monaco/index.tsx:1507, 2099, executeSingleRaw.ts:51, tools/runQuery.ts:109, runDetachedQuery.tsx:49 A re-run statement waits up to about 45 s for the hung slot, then up to 10 s for a new refresh, before anything is sent. onRefreshWait(stalled.remainingMs) is consumed only by useCatalogSource, which is background and never takes this path.
The editor, notebook cells and AI chat show their normal running state, and the persistent toast says "Queries are not being sent". Nothing says that this statement is waiting for the identity provider.
Cancel works and nothing is sent, so this is a UX gap, not a hang. The recovery-screen equivalent is #18 (round-2 #5).
• As in #1; after the backoff, press Run in the editor
• "Running query…" shows for about 45–55 s with no request in the Network tab
Pass a lifecycle from the statement runners and show "Waiting for your identity provider (up to N s)… Cancel or sign in again". Alternatively, update the toast while a stalled renewal is pending. Otherwise drop the remainingMs/hardDeadlineAt plumbing (see #16).
4 Toolbar logout flashes a "keep this tab open" toast React correctness & hooks Minor in-diff: src/providers/AuthProvider.tsx:208-217 with :528-562 After the user confirms a toolbar logout while an upload is being sent:
• abortForSessionEnd moves the upload to the ended-session writes;
• the view stays ready because onEnd skips reloads;
• the passive toast effect runs after window.location.reload() was called.
Until the new document paints, the user sees "Your previous session is still sending 1 request. Keep this tab open…" about an upload they just agreed to cancel. reloadingRef only guards beforeunload. The test at AuthProvider.test.tsx:563-623 doesn't assert the toast stays hidden.
• Start a large CSV upload
• Toolbar Logout, then confirm
• With a throttled or remote server, the warning toast appears before the reload
In the effect, add if (reloadingRef.current) return, and assert in that test that toast.warning is not called.
5 Logout cancels an earlier session's upload without asking Accessibility & UX Minor in-diff: AuthProvider.tsx:162-168, 557-562 confirmReloadingLogout ignores _endedSessionWrites, and reloadingRef disables the beforeunload guard. Path:
• an upload is being sent;
• the session ends (401 or "Sign in again");
• the user signs in with a password in the same tab;
• they close the warning toast, which is closable and not shown again;
• they click Logout.
There is no confirm, the reload aborts the upload, and a partially imported table can result.
This is deliberate and tested ("still reloads at once … while an ended session's upload is sent"). But the stated reason ("staying would keep a console without a session") only applies to a beforeunload prompt after endSession, not to the confirm that runs before it. main also reloaded without asking.
• Password login; start a large upload
• Trigger a 401, then sign in with a password in the same tab
• Close the "still sending" toast and click Logout: no prompt, and the upload is cut off
Also ask when questClient.endedSessionWriteCount() > 0, with the existing "partially imported table" wording. Update that test and the comment.
6 Stale "password" login method deletes another tab's credentials State & context architecture Minor in-diff: AuthProvider.tsx:479-492 (uiAuthLogin else branch), :538-542 93d55dd makes logout() remove the shared password credentials only when LOGIN_METHOD === "password". It never checks whether the tab is signed in, and only logout() clears the method.
A password tab that lands on Login, because another tab removed the credentials, keeps "password". A failed SSO attempt from that Login then calls logout(), which deletes whatever credentials another tab has saved since. Failed attempts include IdP cancel / access_denied, a failed code exchange, a malformed token and a state mismatch.
That tab hits Login at its next reload or Settings recovery. This is a hole in the PR's own "never erase another tab's password credentials" rule, not a regression: main deleted the credentials on every logout.
• Tabs A and B use the password login; B logs out; A reloads and shows Login (method still "password")
• B signs in with a password (new rest.token)
• In A: "Continue with SSO", then cancel at the IdP: rest.token is gone, and B shows Login after a reload
In the uiAuthLogin else branch, clear a stale "password" method. Alternatively, gate the removal on isAuthenticated() && !isSSOAuthenticated(). Add a test: a stale "password" method, then an SSO error callback, and the other tab's REST_TOKEN survives.
7 SSO logout leaves stored password credentials (shared machines) State & context architecture (also found independently by the fresh-context agent) Minor in-diff: AuthProvider.tsx:534-542; caller components/TopBar/toolbar.tsx:628 On main, any logout removed REST_TOKEN/BASIC_AUTH_HEADER. Now an SSO tab's toolbar logout keeps them.
Suppose a password user closed their tab without logging out while an SSO tab was open or resumed. After the SSO user logs out, the next new tab opens as the password user without a prompt. On EE that is a REST token with a 1-day TTL.
main had the same exposure before anyone logged out. What changed is that an explicit SSO "Log out" no longer signs the whole browser out. The PR description documents the per-tab rule, so this is a product decision rather than an oversight.
• EE + OIDC: tab S signs in with SSO
• Tab P signs in with a password and is closed without logging out
• S: toolbar Log out
• Open a new tab: signed in as the password user (Login on main)
Remove the credentials on an explicit toolbar logout (reload && clearSSOSession). That keeps "Sign in again" and 401 logouts tab-local, so round-2 #1 stays fixed. Alternatively, confirm the behaviour is intended.
8 Password logout clears the shared SSO resume flag State & context architecture Minor out-of-diff: toolbar.tsx:628 passes clearSSOSession:true for every login method. Contract: login choice is tab-local, and SSO_SESSION_ACTIVE lets other tabs resume silently (AuthProvider.tsx:637-639). In-diff: AuthProvider.tsx:543-548, handleUnauthorized.ts:24-27 A password tab's toolbar logout, or the 5×401 guard tripping in a password tab, removes SSO_SESSION_ACTIVE. The guard also removes the SSO username. A healthy SSO tab that reloads before its next refresh sets the flag again shows Login instead of resuming. One SSO click recovers it.
This predates the PR. 93d55dd made credential removal depend on the login method, but not this flag.
• Tab A: SSO; tab B: password
• B: Logout
• Reload A before it refreshes: Login
Read LOGIN_METHOD before line 539. Clear the flag (and, in the guard, the SSO username) only when the method isn't "password".
9 Unpublished 401 deletes the chart snapshot Persistence & migrations Minor in-diff: client.ts:770-781, 810 → Notebook/cellRefresh/cellRefreshEngine.ts:220-221, 1340-1357, 1455-1490 This extends round-2 #20, which is still unfixed. A resend's 401 is rejected without publishing MSG_CONNECTION_UNAUTHORIZED if the bearer rotated again during the resend. That happens when a concurrent request's 401 starts a refresh that skips the throttle, which is realistic with several auto-refresh notebook cells while the server rejects freshly issued tokens.
The rejection is the bare {status:401, error:"Unauthorized"}, although execution.requestNotSent is already true. So isNotExecuted misses it:
• a chart cell gets an error frame and deleteCellSnapshot;
• a grid cell saves an "Unauthorized" refresh error;
• Monaco marks it as a SQL error.
The next successful round recovers, but a reload before then loses the saved chart.
• SSO; the server rejects freshly rotated tokens; two or more auto-refresh chart cells
• A: 401, renewal, resend; meanwhile B: 401, which starts another refresh
• A's resend gets a 401 that isn't published: the chart shows "Unauthorized" and its snapshot is deleted
In the 401 (and 403) branch, add position:-1, query, type:Type.ERROR, requestNotSent:true. Add a client test for the double-rotation resend and an engine test that asserts deleteCellSnapshot is not called.
10 Validation and /chk 401s never start a renewal Performance & rendering at scale Minor in-diff: client.ts:821-844, 952-967; the SQL path is at :526-570 Only /exec 401s start a renewal that skips the throttle. In ID-token mode with the browser clock more than 30 s behind the server, or after a server-side rejection, the gap lasts about (skew − 30 s), or until any /exec. Meanwhile, each keystroke's validation and each /chk gets a 401 and renews nothing:
• Monaco markers silently disappear;
• not-yet-classified notebook refresh rounds stop at validation;
• AI/MCP gated runs are denied;
• Import marks the file FAILED.
Manual runs renew at once.
• ID-token mode, browser clock 3 min behind the server; wait for the server-side expiry
• Type in the editor: a validate 401 per edit and no refresh_token POST
• Run any query: it renews
On a 401, mirror the sendQuery guard (payload unchanged, no refresh pending or failed) and call startRefresh once per rejected payload, optionally waiting and resending once.
11 Fast clock refreshes every 5–30 s Performance & rendering at scale Minor in-diff: OAuth2/utils.ts:109-125; tokenRefresh.ts:91-97, 183-195; client.ts:399-403 This refines round-2 #22. With the browser clock ahead by s and token lifetime L:
• for L − 60 s < s < L, a refresh every 5–30 s;
• for s ≥ L, one every 30 s.
Every request sent while a refresh is pending waits for it, though the server accepts the current bearer. With s ≥ L, one transient IdP failure also makes every request fail locally until a retry succeeds.
The throttle is deliberate and tested, so the cost is bounded traffic and latency, not a storm. main was immune because it used now + expires_in.
• ID-token mode, 5-min tokens, clock 4.5 min ahead
• Keep the schema or Query Activity open: a token request about every 15 s, with matching query delays
Don't make requests wait on a proactive refresh while the bearer hasn't expired locally. Leave rejected bearers to the 401 path. Optionally detect skew at install (exp − now ≪ exp − iat) and fall back to now + (exp − iat).
12 Script summary calls interrupted statements "failed" Accessibility & UX Minor in-diff file: Monaco/index.tsx:1777-1789, 1871-1893 The summary still counts auth failures as failed. After signing in again, the collapsed log line, shown in red, reads "Stopped after running 1 successful and 1 failed queries". That statement's own entry (new in this PR) says "Interrupted… may have executed; verify" or "Not executed". The renewal-failure case reads "1 failed query" for a request that "was not executed".
The counter itself predates the PR; the new per-statement wording makes the mismatch visible.
• Run all insert…; <slow insert>
• "Sign in again" during the second statement, then sign in
• The collapsed log says "1 failed"
Count isAuthFailure results separately, e.g. "1 not run" or "1 interrupted (may have executed)".
13 "Sign in again" hard to reach by keyboard Accessibility & UX Minor in-diff: AuthProvider.tsx:289-302, refreshFailedWarning.tsx:45-47, src/index.tsx:96 In the console, the "Sign in again" that doesn't reload exists only in a toast portalled at the end of <body>:
• it is never focused, and react-toastify 9.1.3 has no hotkey;
• from Monaco, a keyboard user needs Ctrl+M and then Tab through the whole UI;
• role=alert does announce the text;
• query errors say "Please retry or sign in again." without saying where.
Toolbar Logout can be reached, but it reloads and clears the SSO flag. Also, a leftover NOTIFICATION_SHORTCUT_* mock remains at AuthProvider.test.tsx:59-60.
• The refresh-failed toast appears while focus is in the editor
• Try to reach "Sign in again" with the keyboard
Focus the button when the toast first appears, or add a "Sign in again" action next to authRefreshUnavailable errors. Say where the action is in the copy.
14 Downloads fail in other SSO tabs Browser compatibility & security Minor out-of-diff: utils/downloadQueryResult.ts:8-48. Contract (2.5d #9): a lost cookie session is reopened by the next SQL request. In-diff: client.ts:94-101, 416-470 There is one qdb_session cookie per origin, but _lostCookieSession is per tab. When tab A destroys the cookie and never reopens it, tab B's cookie-only downloads fail, because B's bearer is valid, so B never sees a 401 and never reopens. A destroys it by:
• a 401 followed by a failed renewal, logout or idling (tandem server);
• logout or "Sign in again" (session=false), which predates the PR.
A reopen by any tab fixes all of them. This extends round-2 #17 (same tab).
• Two SSO tabs, A and B
• In A, log out, or click "Sign in again" and stop at the IdP
• In B, run a query (works), then Download CSV: it fails until B reloads
On a failed download, mark this tab's session lost and retry once after a background select 1 with session=true through fetchExec. This also fixes #33 (round-2 #17). Optionally broadcast the loss to other tabs.
15 Login notice: body larger than its headline Styling & theming Minor in-diff: modules/OAuth2/views/login.tsx:463-468; Container font-size: 16px at :76 The headline is <Text size="lg">, i.e. 1.5rem = 15 px. The body <Text> has no size, so it inherits 16 px. The grey secondary text is larger than the bold headline. The console toast gets the order right. • Start an upload, click "Sign in again", and look at the Login notice Add size="md" (or "sm") to the body Text.
16 Duplicated confirm, refresh-slot and snapshot logic Code structure, readability & types Minor in-diff: AuthProvider.tsx:159-168 vs :624-643, :457, LOGIN_METHOD at :245, 407, 410, 421, 477, 538-539, 686; client.ts:137-142 vs :372-403, :465, :472-482; tokenRefresh.ts:43, 131-139, 201; types.ts:377-397; cellRefreshEngine.ts:1304, 1322, 1351; utils/telemetryQueries.ts:22-25 New in 93d55dd:
• Two copies of the "confirm ending the session" logic, whose copy has already drifted. window.confirm now lives inside logout(); only the toolbar reaches it, but logout no longer always ends the session.
• Two ways to wait on the held slot (prepareRenewalRetry and stalledRenewal) with different rules. Slot identity is compared three ways, and refreshBeforeRequest has a third positional boolean.
• remainingMs/hardDeadlineAt have no production consumer, and the background/onRefreshWait JSDoc doesn't describe the stalled wait.
• The not-executed snapshot guard is copied three times; round-2 #3 came from a missing copy.
• The "cookie session opened" rule is duplicated, and noteCookieSessionNotOpened is a one-line alias.
• Raw "sso"/"password" strings now appear 8 times.
• queryTelemetryConfig defaults to the passive options for the user-initiated settings save.
n/a • One confirmEndingSession helper, and a separate logoutFromToolbar.
• One heldRenewal({userRetry}) API, with slots compared by object.
• A persistRoundSnapshot helper that does nothing when not executed.
• LoginMethod type and helpers.
• Make the queryTelemetryConfig options required.
17 Tests miss batching, the 400 answer and the real-store reset Test review & coverage Minor providers/AuthProvider.test.tsx; src/index.tsx; e2e/tests/enterprise/oidc.spec.js:588-592 • Session-end tests run inside act(), so deleting any unstable_batchedUpdates wrapper keeps every test green, although the React 17 single-commit guarantee depends on them.
• "asks the next SQL request for a session only if the sign-in request %s" has no 400 row.
• The only e2e that asserts no grid rows after signing in again uses toolbar logout, which reloads, and since 93d55dd that skips the store clear. Deleting the clearLoginDataOnSessionEnd wiring in index.tsx keeps every test green.
• #1–#6 and #9 have no tests.
n/a • A logout test outside act, triggered from a promise continuation, that records commits.
• A 400 row.
• In auth.spec.js, "Sign in again" → password, then assert no grid-row and an empty schema.
• Tests for #1–#6 and #9.

Still present from round 2

All of these were re-checked at 93d55dd2. The descriptions, repros and fixes from round 2 still apply; only what changed is noted here.

ID Issue Category Severity Location Description Steps to reproduce Suggested fix
18 Recovery Retry shows only "Reconnecting…" for up to ~60 s (R2 #5) Accessibility & UX Minor in-diff: client.ts:137-142; AuthCheckRecovery.tsx:104-110 Unchanged. Same root cause as #3. As in round 2 As in round 2
19 Refresh-failed toast stacked over the recovery screen (R2 #6) Accessibility & UX Minor in-diff: src/index.tsx:96; AuthProvider.tsx:289-302, 405 Nothing dismisses or suppresses REFRESH_FAILED_TOAST_ID while renewalUnavailable is shown. As in round 2 As in round 2
20 Settings recovery silently switches the password identity (R2 #8) State & context architecture Minor in-diff: AuthProvider.tsx:471-478; authSessionState.ts:44-47; QuestProvider/index.tsx:80-83 markNonSSOAuthenticated still doesn't bump the session id. A remount adopts credentials another tab replaced, and the previous result and schema stay on screen. As in round 2 As in round 2
21 Failed table check imports with new-table defaults (R2 #9) Query execution & data integrity Minor in-diff: ImportCSVFiles/index.tsx:124-130, 155-213, 240-263, 362-399 Unchanged; #1 makes it easier to reach. After a session-end reset, tables is [] until Schema loads, so even an EXISTS check gets partitionBy:"NONE". Overwrite then recreates the table unpartitioned. As in round 2 As in round 2
22 Existing table imported with an empty, locked schema (R2 #10) Query execution & data integrity Minor in-diff: ImportCSVFiles/index.tsx:132-153 getExistingTableSchema still swallows errors. As in round 2 As in round 2
23 Upload blocked by renewal shows a generic "Upload error" (R2 #11) Accessibility & UX Minor in-diff: ImportCSVFiles/index.tsx:345-353 Unchanged. It's more visible now because of #1. As in round 2 As in round 2
24 Paste after a dialog shows a false "duplicate" (R2 #12) React correctness & hooks Minor in-diff file: dropbox.tsx:82-102 Listeners still leak per dialog toggle. The leaked first-render handler also holds a stale onFilesDropped (old tables/ownedByList). As in round 2 As in round 2
25 Catalog reloaded 2–4× after renewal recovery (R2 #13) Performance & rendering at scale Minor in-diff: Schema/index.tsx:174-178, 346-351 Unchanged; the stalled-wait success path also leads into it. As in round 2 As in round 2
26 Variables "Apply" fails silently on a renewal failure (R2 #14) Async, timers & cancellation Minor out-of-diff: Notebook/globals/VariablesPopover.tsx:433-447. Contract (2.5c): validateQuery can wait for a renewal and reject with AuthRefreshUnavailableError handleApply still has no catch. As in round 2 As in round 2
27 Unmounted Schema panels keep querying after logout (R2 #15) State & context architecture Minor in-diff file: Schema/index.tsx:335-382 Still no cleanup for the eventBus and focus listeners. As in round 2 As in round 2
28 MCP pairing carries over to the next login (R2 #16) State & context architecture Minor out-of-diff: MCPBridgeProvider/index.tsx:398-414. Contract: session end clears what the previous login set up Unchanged. As in round 2 As in round 2
29 Downloads fail after a 401 outside /exec (R2 #17) Cross-context caller impact Minor out-of-diff: utils/downloadQueryResult.ts. Contract (2.5d #9) Only the failed-startServerSession case was added. See #14 for the other-tab variant. As in round 2 As in round 2
30 Open notebook drops committed writes at session end (R2 #18) Persistence & migrations Minor in-diff: useCellExecution.ts:1044-1051 vs notebookHeadlessRun.ts:229-246 Headless write runs now record their completed statements; open notebooks still don't. As in round 2 As in round 2
31 Outcome of an ended-session upload never shown (R2 #19) Query execution & data integrity Minor in-diff: client.ts:1021-1040; unfinishedWritesWarning.tsx:14-20 Unchanged. As in round 2 As in round 2
32 Catalog panels blame the network for a renewal failure (R2 #21) Cross-context caller impact Minor out-of-diff: QueryActivity/index.tsx:187, TableDetailsDrawer/index.tsx:768. Contract: authRefreshUnavailable is not a connection failure Unchanged. As in round 2 As in round 2
33 Persistent auth toasts unreadable after a theme switch (R2 #23) Styling & theming Minor out-of-diff root cause: components/Toast/index.tsx:67-128 Unchanged. As in round 2 As in round 2
34 Recovery screen: wrong button label, two primary buttons (R2 #24) Accessibility & UX Minor in-diff: AuthCheckRecovery.tsx:101-114; ConnectionErrorView.tsx:62-72 retryLabel is still never passed, and both buttons are still primary. As in round 2 As in round 2
35 Low-contrast recovery text in the light theme (R2 #25) Styling & theming Minor in-diff: ConnectionErrorView.tsx:49-60 About 3.4:1 and 4.34:1 on authBackdrop. As in round 2 As in round 2
36 New status text isn't announced (R2 #26) Accessibility & UX Minor in-diff: login.tsx:396-397, 460-472; AuthProvider.tsx:211-216; QuestProvider/index.tsx:181-196 All three parts are unchanged. As in round 2 As in round 2
37 "Sign in again" banner title reads like a control (R2 #27) Accessibility & UX Minor in-diff: AuthProvider.tsx:641 Unchanged. As in round 2 As in round 2
38 Enterprise dispatch secret reachable from PR branches (R2 #28) Browser compatibility & security Minor in-diff: .github/workflows/enterprise_e2e.yml:3-33, 65-69 Unchanged. New detail: the branch name is passed to the private pipeline as clientBranch. It is handled safely here (through env and jq --arg), but a private YAML that inlines ${{ parameters.clientBranch }} into a script would be injectable through a branch name. As in round 2 Use a protected Environment. Also validate UI_BRANCH against ^[A-Za-z0-9._/-]+$, and pass parameters through env: in the private lane.
39 Duplicated logic, loose types, leftover literals (R2 #30–#33) Code structure, readability & types Minor See round 2 These are still present: the 4× session-ended message copies; the ERROR→reject normalizers that exist only for test doubles; the inline checkTableName; non-guard isSessionEnded/isAuthRefreshUnavailable; the unread connectionError; status: string; the unused ConnectionErrorView props; raw "Cancelled by user" literals; redundant ensureRequestAllowed; the adjacent signal parameters; nested ternaries. client.ts is now 1,177 lines and AuthProvider.tsx 708. n/a As in round 2
40 Real-server test of the server change hasn't run (R2 #29) Test review & coverage Minor in-diff: .github/workflows/enterprise_e2e.yml:21-27 The dispatch job is still skipped at 93d55dd2. Every required auth e2e test stubs /exec. • Checks on 93d55dd2: "Dispatch Enterprise Web Console E2E" shows as skipped Run the private lane on the head SHA and link the green enterprise-e2e-web-console status before the server PRs merge. This gates the merge order, not this PR.

False positives

These claims, or parts of claims, did not survive verification:

  • Code structure (release(web-console): 0.0.2 #16 draft). Claim: logout() can now return without logging out on the 401/403/invalid_grant paths.
    The confirm only runs when options.reload is set, and only the toolbar passes it. handleSSOUnauthorized, the 403 handler and the OAuth error paths never do. The structural point (a confirm inside logout) stands; there is no behavioural risk.
  • Performance (web-console: add LIMIT -10000 to telemetry query #10 draft). Claim: renewing on a validation or /chk 401 would also keep downloads working.
    Only fetchExec sends session=true. The first 401 closes the cookie session whatever the endpoint, so a validation-triggered renewal wouldn't reopen it. The download breakage belongs to release(web-console): 0.0.2 (test PR does not actually publish version) #14 and bump web-console to 0.0.5 #29.
  • Async (init react components package #2 draft). Claim: each repeated 401 closes the cookie session again.
    The first 401 already closed it. Later ones only set _lostCookieSession again, costing one wasted request each.
  • Query execution / Cross-context (wip test for error range #1 draft). Claim: the window is about 45 s.
    When the refresh starts in the normal 30 s pre-expiry window, the backoff ends before the token expires, so the window is about 30 s. 45 s applies only when the refresh starts after expiry.
  • Accessibility & UX (web console automate npm publish #13 draft). Claim: "Sign in again" is the only way to sign in again inside the console.
    Toolbar Logout can be reached by keyboard and leads to Login. It reloads and clears the shared SSO flag, so it isn't equivalent. Screen-reader users do get the toast text announced through role=alert.
  • React correctness (add test for query run when cursor position is next to ending semicolon #4 draft). Claim: the toast flatly contradicts the confirm.
    Its body does say "logging out cancels it". Only the headline and "keep this tab open" are misleading, and only until the reload paints.
  • Browser compatibility & security (release(web-console): 0.0.2 (test PR does not actually publish version) #14 draft). Claim: tab B stays broken until it reloads.
    Any tab that reopens the session (session=true) sets the origin-wide cookie, which fixes B too. B stays broken only when A logs out, fails to renew, or goes idle.
  • Cross-context caller impact. Claim: quick-vis Draw's rejection is unhandled.
    try/finally without a catch is already on main. The PR only adds background: true.
  • State & context architecture. Claim: Footer subscriptions are never cleaned up.
    True, but Footer/index.tsx is out of the diff, the behaviour predates the PR, and the only effect is harmless state updates on an unmounted component.

Summary

@puzpuzpuz
puzpuzpuz self-requested a review October 21, 2026 15:06
@glasstiger
glasstiger merged commit 821684f into main Oct 21, 2026
6 checks passed
@glasstiger
glasstiger deleted the feat/qwp-entra-token-provider branch October 21, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working security web-console Issues relevant to "web-console" package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants