Skip to content

Commit 4ffbe92

Browse files
committed
Merge branch 'release_26.0' into dev
2 parents 698bb7b + c513048 commit 4ffbe92

30 files changed

Lines changed: 540 additions & 81 deletions

client/src/api/client/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import createClient from "openapi-fetch";
22

3+
import { pendingRequestsMiddleware } from "@/api/client/pendingRequestsMiddleware";
34
import { createRateLimiterMiddleware } from "@/api/client/rateLimiter";
45
import type { GalaxyApiPaths } from "@/api/schema";
56
import { getAppRoot } from "@/onload/loadConfig";
@@ -12,6 +13,9 @@ function getBaseUrl() {
1213
function apiClientFactory() {
1314
const client = createClient<GalaxyApiPaths>({ baseUrl: getBaseUrl() });
1415

16+
// Registered first so aborted requests bypass the rate-limiter queue.
17+
client.use(pendingRequestsMiddleware);
18+
1519
// TODO: Adjust based on server limits (maybe this goes in Galaxy config?)
1620
client.use(
1721
createRateLimiterMiddleware({
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Middleware } from "openapi-fetch";
2+
3+
import { getPendingAbortSignal, SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
4+
5+
/**
6+
* Attaches the shared pending-requests signal to every ``GalaxyApi`` request.
7+
* The ``openapi-fetch`` client uses native ``fetch()`` so the axios
8+
* interceptor does not apply; without this middleware, login/register
9+
* navigations cannot cancel in-flight ``/api/...`` calls and a late
10+
* anonymous-cookie response can clobber the authenticated ``galaxysession``
11+
* cookie. See ``client/src/api/pendingRequests.ts`` for the race.
12+
*/
13+
export const pendingRequestsMiddleware: Middleware = {
14+
async onRequest({ request }) {
15+
if (request.headers.has(SKIP_PENDING_REQUESTS_HEADER)) {
16+
const headers = new Headers(request.headers);
17+
headers.delete(SKIP_PENDING_REQUESTS_HEADER);
18+
return new Request(request, { headers });
19+
}
20+
const shared = getPendingAbortSignal();
21+
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([request.signal, shared]) : shared;
22+
return new Request(request, { signal });
23+
},
24+
};

client/src/api/pendingRequests.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Shared ``AbortController`` used by both the axios interceptor and the
3+
* ``openapi-fetch`` middleware so we can cancel every in-flight request in
4+
* one shot right before a login/register navigation.
5+
*
6+
* Why this exists: when the server processes a request that carries the old
7+
* anonymous ``galaxysession`` cookie *after* ``handle_user_login`` has marked
8+
* that session ``is_valid=False``, it creates a fresh anonymous session and
9+
* responds with ``Set-Cookie: galaxysession=<new>``. Delivered into the cookie
10+
* jar after the login response but before the new page loads, it overwrites
11+
* the just-issued authenticated cookie — so the new page loads anonymous and
12+
* ``wait_for_logged_in`` times out in selenium. Aborting the TCP connection
13+
* before the response headers are parsed prevents that ``Set-Cookie`` from
14+
* ever applying.
15+
*/
16+
import axios, { type InternalAxiosRequestConfig } from "axios";
17+
18+
let activeController = new AbortController();
19+
20+
/** Explicit opt-out header that the login/register POST itself sets. */
21+
export const SKIP_PENDING_REQUESTS_HEADER = "x-galaxy-skip-pending-abort";
22+
23+
/**
24+
* The signal every request should ride on by default. Read lazily so the
25+
* ``openapi-fetch`` middleware picks up the fresh signal after each
26+
* ``cancelPendingRequests()`` rotation.
27+
*/
28+
export function getPendingAbortSignal(): AbortSignal {
29+
return activeController.signal;
30+
}
31+
32+
/**
33+
* Install a request interceptor that attaches the shared signal to every
34+
* outgoing axios request that didn't set one itself. Call once at app boot.
35+
*/
36+
export function installPendingRequestsInterceptor() {
37+
axios.interceptors.request.use((config: InternalAxiosRequestConfig) => {
38+
if (config.signal !== undefined) {
39+
return config;
40+
}
41+
if (config.headers?.[SKIP_PENDING_REQUESTS_HEADER]) {
42+
delete config.headers[SKIP_PENDING_REQUESTS_HEADER];
43+
return config;
44+
}
45+
config.signal = activeController.signal;
46+
return config;
47+
});
48+
}
49+
50+
/**
51+
* Abort every request that is using the shared signal (both axios via the
52+
* interceptor and ``openapi-fetch`` via its middleware) and install a fresh
53+
* controller so subsequent requests can still go out.
54+
*/
55+
export function cancelPendingRequests() {
56+
activeController.abort();
57+
activeController = new AbortController();
58+
}

client/src/components/History/TargetHistoryLink.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const isCurrentTargetHistory = computed(() => {
3838
v-else
3939
v-g-tooltip.hover
4040
data-description="not current history indicator"
41-
class="text-warning"
41+
class="text-warning text-nowrap"
4242
title="This history is not your currently active history. You can click the link to switch to it.">
4343
(not current)
4444
</span>

client/src/components/Login/LoginForm.vue

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
import { computed, ref } from "vue";
1616
import { useRouter } from "vue-router/composables";
1717
18+
import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
19+
import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation";
1820
import localize from "@/utils/localization";
1921
import { withPrefix } from "@/utils/redirect";
2022
import { errorMessageAsString } from "@/utils/simple-error";
@@ -86,13 +88,22 @@ async function submitLogin() {
8688
redirect = props.redirect ?? null;
8789
}
8890
91+
// Stop polling and abort in-flight axios/GalaxyApi before sending the
92+
// login POST — otherwise a late anonymous-cookie response can overwrite
93+
// the authenticated cookie we're about to receive.
94+
discardActiveConnectionsBeforeAuthNavigation();
95+
8996
try {
90-
const response = await axios.post(withPrefix("/user/login"), {
91-
login: login.value,
92-
password: password.value,
93-
redirect: redirect,
94-
session_csrf_token: props.sessionCsrfToken,
95-
});
97+
const response = await axios.post(
98+
withPrefix("/user/login"),
99+
{
100+
login: login.value,
101+
password: password.value,
102+
redirect: redirect,
103+
session_csrf_token: props.sessionCsrfToken,
104+
},
105+
{ headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } },
106+
);
96107
97108
if (response.data.message && response.data.status) {
98109
alert(response.data.message);

client/src/components/ProgressBar.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ const props = withDefaults(defineProps<Props>(), {
4343
position: absolute;
4444
text-align: center;
4545
width: 100%;
46+
white-space: nowrap;
47+
overflow: hidden;
48+
text-overflow: ellipsis;
4649
}
4750
.progress-container {
4851
position: relative;

client/src/components/Register/RegisterForm.vue

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
} from "bootstrap-vue";
1515
import { computed, type Ref, ref } from "vue";
1616
17+
import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
1718
import { getOIDCIdpsWithRegistration, type OIDCConfig } from "@/components/User/ExternalIdentities/ExternalIDHelper";
1819
import { Toast } from "@/composables/toast";
20+
import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation";
1921
import localize from "@/utils/localization";
2022
import { withPrefix } from "@/utils/redirect";
2123
import { errorMessageAsString } from "@/utils/simple-error";
@@ -72,15 +74,24 @@ const registerColumnDisplay = computed(() => Boolean(props.termsUrl));
7274
async function submit() {
7375
disableCreate.value = true;
7476
77+
// Stop polling and abort in-flight axios/GalaxyApi before sending the
78+
// register POST — otherwise a late anonymous-cookie response can overwrite
79+
// the authenticated cookie we're about to receive.
80+
discardActiveConnectionsBeforeAuthNavigation();
81+
7582
try {
76-
const response = await axios.post(withPrefix("/user/create"), {
77-
email: email.value,
78-
username: username.value,
79-
password: password.value,
80-
confirm: confirm.value,
81-
subscribe: subscribe.value,
82-
session_csrf_token: props.sessionCsrfToken,
83-
});
83+
const response = await axios.post(
84+
withPrefix("/user/create"),
85+
{
86+
email: email.value,
87+
username: username.value,
88+
password: password.value,
89+
confirm: confirm.value,
90+
subscribe: subscribe.value,
91+
session_csrf_token: props.sessionCsrfToken,
92+
},
93+
{ headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } },
94+
);
8495
8596
if (response.data.message && response.data.status) {
8697
Toast.info(response.data.message);

client/src/components/Workflow/WorkflowAnnotation.vue

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const workflowTags = computed(() => {
4242
<template>
4343
<div v-if="workflow" class="pb-2 pl-2">
4444
<div class="d-flex justify-content-between align-items-center">
45-
<div>
45+
<div class="annotation-left">
4646
<i v-if="timeElapsed" data-description="workflow annotation time info">
4747
<FontAwesomeIcon :icon="faClock" class="mr-1" />
4848
<span v-localize>
@@ -52,9 +52,11 @@ const workflowTags = computed(() => {
5252
</i>
5353
<TargetHistoryLink v-if="props.invocationCreateTime" :target-history-id="props.historyId" />
5454
</div>
55-
<slot name="middle-content" />
56-
<div class="d-flex align-items-center">
57-
<div class="d-flex flex-column align-items-end mr-2 flex-gapy-1">
55+
<div class="annotation-middle">
56+
<slot name="middle-content" />
57+
</div>
58+
<div class="annotation-right">
59+
<div class="annotation-right-content">
5860
<WorkflowIndicators :workflow="workflow" published-view no-edit-time />
5961
<WorkflowInvocationsCount v-if="owned" class="mr-1" :workflow="workflow" />
6062
</div>
@@ -69,16 +71,66 @@ const workflowTags = computed(() => {
6971
</template>
7072

7173
<style scoped lang="scss">
72-
.history-link-wrapper {
73-
max-width: 300px;
74-
75-
&:deep(.history-link) {
76-
.history-link-click {
77-
overflow: hidden;
78-
white-space: nowrap;
79-
text-overflow: ellipsis;
80-
display: block;
81-
}
74+
// Left column: 35% of the width
75+
.annotation-left {
76+
flex: 1 1 0;
77+
max-width: 35%;
78+
min-width: 0;
79+
overflow: hidden;
80+
81+
// Ensure neither the history name nor "(current)" escape the column.
82+
:deep(.history-link-wrapper) {
83+
min-width: 0;
84+
overflow: hidden;
85+
}
86+
87+
// SwitchToHistoryLink root div must be able to shrink to 0 so the
88+
// "(current)" label stays visible as long as there is any room.
89+
:deep(.history-link-wrapper > div) {
90+
min-width: 0;
91+
overflow: hidden;
92+
}
93+
94+
:deep(.history-link) {
95+
min-width: 0;
96+
}
97+
98+
:deep(.history-link-click) {
99+
overflow: hidden;
100+
white-space: nowrap;
101+
text-overflow: ellipsis;
102+
display: block;
103+
}
104+
}
105+
106+
// Middle column: 30% of the width
107+
.annotation-middle {
108+
flex: 1 1 0;
109+
max-width: 30%;
110+
min-width: 0;
111+
}
112+
113+
// Right column: 35% of the width
114+
.annotation-right {
115+
flex: 1 1 0;
116+
max-width: 35%;
117+
min-width: 0;
118+
overflow: hidden;
119+
justify-content: flex-end;
120+
121+
.annotation-right-content {
122+
display: flex;
123+
flex-direction: column;
124+
align-items: flex-end;
125+
gap: 0.25rem;
126+
min-width: 0;
127+
overflow: hidden;
128+
}
129+
130+
// Ensure creator badges stay within the column instead of overflowing
131+
:deep(.workflow-indicators) {
132+
flex-wrap: wrap;
133+
justify-content: flex-end;
82134
}
83135
}
84136
</style>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { cancelPendingRequests } from "@/api/pendingRequests";
2+
import { useEntryPointStore } from "@/stores/entryPointStore";
3+
import { useHistoryStore } from "@/stores/historyStore";
4+
import { useNotificationsStore } from "@/stores/notificationsStore";
5+
6+
/**
7+
* Tear down every long-lived connection and cancel every in-flight request
8+
* (both axios and ``openapi-fetch``/GalaxyApi) so that nothing issued under
9+
* the old anonymous ``galaxysession`` cookie can land on the server after
10+
* ``handle_user_login`` has invalidated it. See
11+
* ``client/src/api/pendingRequests.ts`` for the race this guards against.
12+
*
13+
* Call this synchronously as the first step of a login or registration
14+
* submit, before the authenticating POST goes out. The shared abort
15+
* controller is rotated, so the login/register POST (issued right after)
16+
* will use a fresh signal and is not affected.
17+
*/
18+
export function discardActiveConnectionsBeforeAuthNavigation() {
19+
// Stop polling watchers first so they can't kick off new fetches, then
20+
// abort any requests still in flight via the shared AbortController.
21+
useHistoryStore().stopWatchingHistory();
22+
useEntryPointStore().stopWatchingEntryPoints();
23+
useNotificationsStore().stopWatchingNotifications();
24+
cancelPendingRequests();
25+
}

client/src/entry/analysis/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { createPinia, PiniaVuePlugin } from "pinia";
33
import Vue from "vue";
44

5+
import { installPendingRequestsInterceptor } from "@/api/pendingRequests";
56
import { initGalaxyInstance } from "@/app";
67
import { initSentry } from "@/app/addons/sentry";
78
import { initWebhooks } from "@/app/addons/webhooks";
@@ -13,6 +14,12 @@ import App from "./App.vue";
1314
Vue.use(PiniaVuePlugin);
1415
const pinia = createPinia();
1516

17+
// Attach the shared AbortController signal to every outgoing axios request
18+
// so we can cancel in-flight anonymous-cookie requests before login/register
19+
// navigates — otherwise their late ``Set-Cookie: galaxysession=<anon>`` can
20+
// clobber the authenticated cookie.
21+
installPendingRequestsInterceptor();
22+
1623
window.addEventListener("load", async () => {
1724
// Create Galaxy object
1825
const Galaxy = await initGalaxyInstance();

0 commit comments

Comments
 (0)