Skip to content

Commit ccda24a

Browse files
authored
Merge pull request #71 from aws-solutions/release/v1.2.2
2 parents 904f727 + 86943b1 commit ccda24a

12 files changed

Lines changed: 94 additions & 88 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.2.2] - 2026-06-29
9+
10+
### Fixed
11+
12+
- Stacks could become stuck in `DELETE_FAILED` state during teardown.
13+
- Non-admin participants received a "You don't have permission" error on the live race page.
14+
- Corrected the minimum speed helper text on the create model page to match the API validation value.
15+
816
## [1.2.1] - 2026-06-22
917

1018
### Security

solution-manifest.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
id: SO0310
22
name: deepracer-on-aws
3-
version: v1.2.1
3+
version: v1.2.2
44
cloudformation_templates:
55
- template: deepracer-on-aws.template
66
main_template: true

source/apps/infra/lib/constructs/live-race/__tests__/liveRaceEvents.spec.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,26 @@ describe('LiveRaceEvents', () => {
8888
PolicyDocument: {
8989
Statement: Match.arrayWith([
9090
Match.objectLike({
91-
Action: Match.arrayWith(['iot:ListTargetsForPolicy', 'iot:DetachPolicy']),
91+
Action: 'iot:ListTargetsForPolicy',
9292
Effect: 'Allow',
9393
}),
9494
]),
9595
},
9696
}),
9797
).not.toThrow();
98+
expect(() =>
99+
template.hasResourceProperties('AWS::IAM::Policy', {
100+
PolicyDocument: {
101+
Statement: Match.arrayWith([
102+
Match.objectLike({
103+
Action: 'iot:DetachPolicy',
104+
Effect: 'Allow',
105+
Resource: '*',
106+
}),
107+
]),
108+
},
109+
}),
110+
).not.toThrow();
98111
expect(() =>
99112
template.hasResourceProperties('AWS::IAM::Policy', {
100113
PolicyDocument: {

source/apps/infra/lib/constructs/live-race/liveRaceEvents.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,19 @@ export class LiveRaceEvents extends Construct {
9090
deletePolicyEventHandlerFn.addToRolePolicy(
9191
new PolicyStatement({
9292
effect: Effect.ALLOW,
93-
actions: ['iot:ListTargetsForPolicy', 'iot:DetachPolicy'],
93+
actions: ['iot:ListTargetsForPolicy'],
9494
resources: [this.spectatorPolicyArn],
9595
}),
9696
);
97+
// iot:DetachPolicy evaluates against the target (a Cognito Identity ID, not ARN-able),
98+
// so it must be scoped to '*'. Blast radius is bounded by the preceding ListTargetsForPolicy.
99+
deletePolicyEventHandlerFn.addToRolePolicy(
100+
new PolicyStatement({
101+
effect: Effect.ALLOW,
102+
actions: ['iot:DetachPolicy'],
103+
resources: ['*'],
104+
}),
105+
);
97106
const deletePolicyIsCompleteFn = new NodeLambdaFunction(this, 'DeleteIoTPolicyIsCompleteFn', {
98107
entry: path.join(__dirname, '../../../../../libs/lambda/src/live-race/deleteIotPolicy.ts'),
99108
functionName: `${namespace}-LiveRace-DeleteIoTPolicy-IsComplete`,

source/apps/website/src/hooks/useLiveRaceMqtt.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ const BASE_RETRY_MS = 1000;
3232
const MAX_RETRY_MS = 30000;
3333

3434
const getRetryDelay = (attempt: number): number => {
35-
return Math.min(BASE_RETRY_MS * Math.pow(2, attempt), MAX_RETRY_MS) + Math.random() * 1000;
35+
// Create holding unsigned 32 bit array of length 1
36+
const array = new Uint32Array(1);
37+
// Modify array in place
38+
crypto.getRandomValues(array);
39+
// Normalize to [0, 1) (exclusive of 1). 0xffffffff + 1 is used because we provided 32 bit array; need to divide by Uint32.Max + 1
40+
const random = array[0] / (0xffffffff + 1);
41+
return Math.min(BASE_RETRY_MS * Math.pow(2, attempt), MAX_RETRY_MS) + random * 1000;
3642
};
3743

3844
const callAttachPolicy = async (signal: AbortSignal): Promise<void> => {

source/apps/website/src/i18n/en/createModel.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@
147147
"rightSteeringAngle": "Right steering angle",
148148
"rightSteeringAngleConstraintText": "Values are between -30 and 0.",
149149
"minimumSpeed": "Minimum speed",
150-
"speedConstraintText": "Values are between 0.1 and 4.",
150+
"speedConstraintText": "Values are between 0.5 and 4.",
151151
"maximumSpeed": "Maximum speed",
152152
"resetButton": "Reset to default values",
153153
"graphLabel": "Dynamic sector graph",

source/apps/website/src/pages/LiveRace/LiveRace.spec.tsx

Lines changed: 12 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
Leaderboard,
1515
LiveEventStatus,
1616
ListLiveQueueItemsCommand,
17-
ListProfilesCommand,
1817
} from '@deepracer-indy/typescript-client';
1918
import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
2019
import { describe, expect, it, vi } from 'vitest';
@@ -1207,40 +1206,19 @@ describe('<LiveRace />', () => {
12071206
status: 'IN_PROGRESS' as const,
12081207
resetCount: 0,
12091208
submittedAt: new Date('2026-01-01T00:00:00Z'),
1209+
avatar: { top: 'short', skinColor: 'light', eyes: 'default' },
12101210
},
12111211
];
12121212

1213-
it('renders RacerInfoBanner when profiles and queue items are both available', async () => {
1214-
mockDeepRacerClient.on(GetLiveRaceStateCommand).resolves(baseRaceState);
1215-
mockDeepRacerClient.on(ListLiveQueueItemsCommand).resolves({ items: queueItems });
1216-
mockDeepRacerClient.on(ListProfilesCommand).resolves({
1217-
profiles: [
1218-
{
1219-
profileId: 'profile-alice',
1220-
alias: 'alice',
1221-
avatar: { top: 'short', skinColor: 'light', eyes: 'default' },
1222-
computeMinutesUsed: 0,
1223-
computeMinutesQueued: 0,
1224-
maxTotalComputeMinutes: 600,
1225-
maxModelCount: 10,
1226-
},
1227-
],
1228-
});
1229-
1230-
render(<LiveRace />, {
1231-
componentRoute: '/races/:leaderboardId/live',
1232-
initialRouteEntries: ['/races/test-lb/live'],
1233-
});
1234-
1235-
await waitFor(() => {
1236-
expect(screen.getByTestId('racer-info-banner')).toBeInTheDocument();
1213+
it('renders RacerInfoBanner when queue items are available with avatar data', async () => {
1214+
mockDeepRacerClient.on(GetLiveRaceStateCommand).resolves({
1215+
...baseRaceState,
1216+
currentEvaluation: {
1217+
...baseRaceState.currentEvaluation,
1218+
avatar: { top: 'short', skinColor: 'light', eyes: 'default' },
1219+
},
12371220
});
1238-
});
1239-
1240-
it('renders RacerInfoBanner even when no matching profile exists (graceful fallback)', async () => {
1241-
mockDeepRacerClient.on(GetLiveRaceStateCommand).resolves(baseRaceState);
12421221
mockDeepRacerClient.on(ListLiveQueueItemsCommand).resolves({ items: queueItems });
1243-
mockDeepRacerClient.on(ListProfilesCommand).resolves({ profiles: [] });
12441222

12451223
render(<LiveRace />, {
12461224
componentRoute: '/races/:leaderboardId/live',
@@ -1252,30 +1230,20 @@ describe('<LiveRace />', () => {
12521230
});
12531231
});
12541232

1255-
it('renders RacerInfoBanner even when profiles resolve after queue items', async () => {
1233+
it('renders RacerInfoBanner even when avatar is missing (graceful fallback)', async () => {
12561234
mockDeepRacerClient.on(GetLiveRaceStateCommand).resolves(baseRaceState);
1257-
mockDeepRacerClient.on(ListLiveQueueItemsCommand).resolves({ items: queueItems });
1258-
1259-
let resolveProfiles!: (value: unknown) => void;
1260-
mockDeepRacerClient.on(ListProfilesCommand).callsFake(
1261-
() =>
1262-
new Promise((resolve) => {
1263-
resolveProfiles = resolve;
1264-
}),
1265-
);
1235+
mockDeepRacerClient.on(ListLiveQueueItemsCommand).resolves({
1236+
items: [{ ...queueItems[0], avatar: undefined }],
1237+
});
12661238

12671239
render(<LiveRace />, {
12681240
componentRoute: '/races/:leaderboardId/live',
12691241
initialRouteEntries: ['/races/test-lb/live'],
12701242
});
12711243

1272-
// Banner renders before profiles arrive
12731244
await waitFor(() => {
12741245
expect(screen.getByTestId('racer-info-banner')).toBeInTheDocument();
12751246
});
1276-
1277-
// Profiles resolve late — should not crash
1278-
resolveProfiles({ profiles: [] });
12791247
});
12801248
});
12811249
});

source/apps/website/src/pages/LiveRace/LiveRace.tsx

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import ProgressBar from '@cloudscape-design/components/progress-bar';
1212
import SpaceBetween from '@cloudscape-design/components/space-between';
1313
import StatusIndicator from '@cloudscape-design/components/status-indicator';
1414
import { UserGroups } from '@deepracer-indy/typescript-client';
15-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15+
import { useCallback, useEffect, useRef, useState } from 'react';
1616
import { useTranslation } from 'react-i18next';
1717
import { useParams, useSearchParams } from 'react-router-dom';
1818

@@ -34,7 +34,7 @@ import {
3434
useReorderLiveQueueMutation,
3535
useResetLiveQueueModelMutation,
3636
} from '#services/deepRacer/leaderboardsApi.js';
37-
import { useGetProfileQuery, useListProfilesQuery } from '#services/deepRacer/profileApi.js';
37+
import { useGetProfileQuery } from '#services/deepRacer/profileApi.js';
3838
import { displayInfoNotification, displaySuccessNotification } from '#store/notifications/notificationsSlice.js';
3939
import { checkUserGroupMembership } from '#utils/authUtils.js';
4040
import { millisToMinutesAndSeconds } from '#utils/dateTimeUtils.js';
@@ -88,7 +88,6 @@ const LiveRace = ({ __forceFacilitator }: LiveRaceProps = {}) => {
8888
);
8989
const { data: leaderboard } = useGetLeaderboardQuery({ leaderboardId });
9090
const { data: profile } = useGetProfileQuery();
91-
const { data: profilesData } = useListProfilesQuery();
9291

9392
// Seed reducer state from REST response
9493
useEffect(() => {
@@ -115,6 +114,7 @@ const LiveRace = ({ __forceFacilitator }: LiveRaceProps = {}) => {
115114
? {
116115
participantName: liveRaceState.currentEvaluation.participantName,
117116
modelName: liveRaceState.currentEvaluation.modelName,
117+
currentAvatar: liveRaceState.currentEvaluation.avatar ?? null,
118118
streamUrl: liveRaceState.currentEvaluation.streamUrl ?? null,
119119
isExecutionRunning: true,
120120
}
@@ -138,10 +138,6 @@ const LiveRace = ({ __forceFacilitator }: LiveRaceProps = {}) => {
138138

139139
// Seed queue items from REST response
140140
const lastQueueRef = useRef<string>('');
141-
const profileAvatarMap = useMemo(
142-
() => new Map((profilesData ?? []).map((p) => [p.profileId, p.avatar])),
143-
[profilesData],
144-
);
145141
useEffect(() => {
146142
if (!liveQueueData?.items) return;
147143
const key = liveQueueData.items.map((i) => `${i.submissionId}:${i.status}`).join(',');
@@ -155,7 +151,7 @@ const LiveRace = ({ __forceFacilitator }: LiveRaceProps = {}) => {
155151
queuePosition: item.queuePosition,
156152
status: item.status as 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED',
157153
submittedAt: typeof item.submittedAt === 'string' ? item.submittedAt : new Date(item.submittedAt).toISOString(),
158-
avatar: profileAvatarMap.get(item.profileId),
154+
avatar: item.avatar,
159155
}));
160156
const nextPending = items.find((i) => i.status === 'PENDING');
161157
setRaceState((prev) => ({
@@ -169,25 +165,7 @@ const LiveRace = ({ __forceFacilitator }: LiveRaceProps = {}) => {
169165
}
170166
: {}),
171167
}));
172-
}, [liveQueueData, profileAvatarMap]);
173-
174-
// When profiles load after queue items, back-fill avatars on existing queue items
175-
useEffect(() => {
176-
if (!profilesData?.length) return;
177-
setRaceState((prev) => {
178-
const enriched = prev.queueItems.map((item) =>
179-
item.avatar ? item : { ...item, avatar: profileAvatarMap.get(item.profileId ?? '') },
180-
);
181-
const currentItem = enriched.find(
182-
(i) => i.participantName === prev.participantName && i.status === 'IN_PROGRESS',
183-
);
184-
return {
185-
...prev,
186-
queueItems: enriched,
187-
currentAvatar: currentItem?.avatar ?? prev.currentAvatar,
188-
};
189-
});
190-
}, [profilesData, profileAvatarMap]);
168+
}, [liveQueueData]);
191169

192170
// Check facilitator role
193171

source/libs/lambda/src/api/handlers/getLiveRaceState.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import type { Operation } from '@aws-smithy/server-common';
5-
import { leaderboardDao, liveQueueItemDao, rankingDao, submissionDao, ResourceId } from '@deepracer-indy/database';
5+
import {
6+
leaderboardDao,
7+
liveQueueItemDao,
8+
profileDao,
9+
rankingDao,
10+
submissionDao,
11+
ResourceId,
12+
} from '@deepracer-indy/database';
613
import {
714
BadRequestError,
815
getGetLiveRaceStateHandler,
@@ -36,17 +43,20 @@ export const GetLiveRaceStateOperation: Operation<
3643
const currentItem = queue.find((queueItem) => queueItem.status === LiveQueueItemStatus.IN_PROGRESS);
3744

3845
let streamUrl: string | undefined;
46+
let avatar: Awaited<ReturnType<typeof profileDao.load>>['avatar'] | undefined;
3947
if (currentItem) {
40-
try {
41-
const submission = await submissionDao.get({
42-
profileId: currentItem.profileId,
43-
leaderboardId,
44-
submissionId: currentItem.submissionId as ResourceId,
45-
});
46-
streamUrl = submission?.videoStreamUrl;
47-
} catch {
48-
/** no-op — streamUrl remains undefined */
49-
}
48+
const [submission, profile] = await Promise.all([
49+
submissionDao
50+
.get({
51+
profileId: currentItem.profileId,
52+
leaderboardId,
53+
submissionId: currentItem.submissionId as ResourceId,
54+
})
55+
.catch(() => undefined),
56+
profileDao.load({ profileId: currentItem.profileId }).catch(() => undefined),
57+
]);
58+
streamUrl = submission?.videoStreamUrl;
59+
avatar = profile?.avatar;
5060
}
5161

5262
return {
@@ -65,6 +75,7 @@ export const GetLiveRaceStateOperation: Operation<
6575
modelName: currentItem.modelName,
6676
status: currentItem.status,
6777
streamUrl,
78+
avatar,
6879
}
6980
: undefined,
7081
queue: {

source/libs/lambda/src/api/handlers/listLiveQueueItems.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import type { Operation } from '@aws-smithy/server-common';
5-
import { liveQueueItemDao, type ResourceId } from '@deepracer-indy/database';
5+
import { liveQueueItemDao, profileDao, type ResourceId } from '@deepracer-indy/database';
66
import {
77
getListLiveQueueItemsHandler,
88
type ListLiveQueueItemsServerInput,
@@ -22,6 +22,14 @@ export const ListLiveQueueItemsOperation: Operation<
2222
const leaderboardId = input.leaderboardId as ResourceId;
2323
const queueItems = await liveQueueItemDao.getQueue({ leaderboardId });
2424

25+
// Avatars live on the profile, not the queue item. Batch-load the unique profileIds
26+
// referenced by the queue and attach each item's avatar from the resulting map.
27+
const uniqueProfileIds = Array.from(new Set(queueItems.map((item) => item.profileId)));
28+
const profiles = uniqueProfileIds.length
29+
? await profileDao.batchGet(uniqueProfileIds.map((profileId) => ({ profileId }))).catch(() => [])
30+
: [];
31+
const avatarByProfileId = new Map(profiles.map((profile) => [profile.profileId, profile.avatar]));
32+
2533
const items: LiveQueueItem[] = queueItems.map((item) => ({
2634
leaderboardId: item.leaderboardId,
2735
submissionId: item.submissionId,
@@ -33,6 +41,7 @@ export const ListLiveQueueItemsOperation: Operation<
3341
status: item.status,
3442
resetCount: item.resetCount,
3543
submittedAt: new Date(item.submittedAt),
44+
avatar: avatarByProfileId.get(item.profileId),
3645
}));
3746

3847
return { items } satisfies ListLiveQueueItemsServerOutput;

0 commit comments

Comments
 (0)