Skip to content

Commit 265ae26

Browse files
committed
Nudge the user to kill programs using excessive CPU
1 parent 51ec48e commit 265ae26

File tree

13 files changed

+280
-6
lines changed

13 files changed

+280
-6
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
require 'json'
2+
3+
require 'spec_helper'
4+
require 'support/editor'
5+
require 'support/playground_actions'
6+
7+
RSpec.feature "Excessive executions", type: :feature, js: true do
8+
include PlaygroundActions
9+
10+
before do
11+
visit "/?#{config_overrides}"
12+
editor.set(code)
13+
end
14+
15+
scenario "a notification is shown" do
16+
within(:header) { click_on("Run") }
17+
within(:notification, text: 'will be automatically killed') do
18+
expect(page).to have_button 'Kill the process now'
19+
expect(page).to have_button 'Allow the process to continue'
20+
end
21+
end
22+
23+
scenario "the process is automatically killed if nothing is done" do
24+
within(:header) { click_on("Run") }
25+
expect(page).to have_selector(:notification, text: 'will be automatically killed', wait: 2)
26+
expect(page).to_not have_selector(:notification, text: 'will be automatically killed', wait: 4)
27+
expect(page).to have_content("Exited with signal 9")
28+
end
29+
30+
scenario "the process can continue running" do
31+
within(:header) { click_on("Run") }
32+
within(:notification, text: 'will be automatically killed') do
33+
click_on 'Allow the process to continue'
34+
end
35+
within(:output, :stdout) do
36+
expect(page).to have_content("Exited normally")
37+
end
38+
end
39+
40+
def editor
41+
Editor.new(page)
42+
end
43+
44+
def code
45+
<<~EOF
46+
use std::time::{Duration, Instant};
47+
48+
fn main() {
49+
let start = Instant::now();
50+
while start.elapsed() < Duration::from_secs(5) {}
51+
println!("Exited normally");
52+
}
53+
EOF
54+
end
55+
56+
def config_overrides
57+
config = {
58+
killGracePeriodS: 3.0,
59+
excessiveExecutionTimeS: 0.5,
60+
}
61+
62+
"whte_rbt.obj=#{config.to_json}"
63+
end
64+
end

tests/spec/spec_helper.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@
8383
css { '[data-test-id = "stdin"]' }
8484
end
8585

86+
Capybara.add_selector(:notification) do
87+
css { '[data-test-id = "notification"]' }
88+
end
89+
8690
RSpec.configure do |config|
8791
config.after(:example, :js) do
8892
page.execute_script <<~JS

ui/frontend/.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ module.exports = {
8686
'editor/AceEditor.tsx',
8787
'editor/SimpleEditor.tsx',
8888
'hooks.ts',
89+
'observer.ts',
8990
'reducers/browser.ts',
9091
'reducers/client.ts',
9192
'reducers/code.ts',

ui/frontend/.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ node_modules
2626
!editor/AceEditor.tsx
2727
!editor/SimpleEditor.tsx
2828
!hooks.ts
29+
!observer.ts
2930
!reducers/browser.ts
3031
!reducers/client.ts
3132
!reducers/code.ts

ui/frontend/Notifications.module.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,10 @@ $space: 0.25em;
2828
background: #e1e1db;
2929
padding: $space;
3030
}
31+
32+
.action {
33+
display: flex;
34+
justify-content: center;
35+
padding-top: 0.5em;
36+
gap: 0.5em;
37+
}

ui/frontend/Notifications.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Portal } from 'react-portal';
44
import { Close } from './Icon';
55
import { useAppDispatch, useAppSelector } from './hooks';
66
import { seenRustSurvey2022 } from './reducers/notifications';
7+
import { allowLongRun, wsExecuteKillCurrent } from './reducers/output/execute';
78
import * as selectors from './selectors';
89

910
import styles from './Notifications.module.css';
@@ -15,6 +16,7 @@ const Notifications: React.FC = () => {
1516
<Portal>
1617
<div className={styles.container}>
1718
<RustSurvey2022Notification />
19+
<ExcessiveExecutionNotification />
1820
</div>
1921
</Portal>
2022
);
@@ -36,13 +38,36 @@ const RustSurvey2022Notification: React.FC = () => {
3638
) : null;
3739
};
3840

41+
const ExcessiveExecutionNotification: React.FC = () => {
42+
const showExcessiveExecution = useAppSelector(selectors.excessiveExecutionSelector);
43+
const time = useAppSelector(selectors.excessiveExecutionTimeSelector);
44+
const gracePeriod = useAppSelector(selectors.killGracePeriodTimeSelector);
45+
46+
const dispatch = useAppDispatch();
47+
const allow = useCallback(() => dispatch(allowLongRun()), [dispatch]);
48+
const kill = useCallback(() => dispatch(wsExecuteKillCurrent()), [dispatch]);
49+
50+
return showExcessiveExecution ? (
51+
<Notification onClose={allow}>
52+
The running process has used more than {time} of CPU time. This is often caused by an error in
53+
the code. As the playground is a shared resource, the process will be automatically killed in{' '}
54+
{gracePeriod}. You can always kill the process manually via the menu at the bottom of the
55+
screen.
56+
<div className={styles.action}>
57+
<button onClick={kill}>Kill the process now</button>
58+
<button onClick={allow}>Allow the process to continue</button>
59+
</div>
60+
</Notification>
61+
) : null;
62+
};
63+
3964
interface NotificationProps {
4065
children: React.ReactNode;
4166
onClose: () => void;
4267
}
4368

4469
const Notification: React.FC<NotificationProps> = ({ onClose, children }) => (
45-
<div className={styles.notification}>
70+
<div className={styles.notification} data-test-id="notification">
4671
<div className={styles.notificationContent}>{children}</div>
4772
<button className={styles.close} onClick={onClose}>
4873
<Close />

ui/frontend/configureStore.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { produce } from 'immer';
33
import { merge } from 'lodash-es';
44

55
import initializeLocalStorage from './local_storage';
6+
import { observer } from './observer';
67
import reducer from './reducers';
78
import initializeSessionStorage from './session_storage';
89
import { websocketMiddleware } from './websocketMiddleware';
@@ -33,7 +34,8 @@ export default function configureStore(window: Window) {
3334
const store = reduxConfigureStore({
3435
reducer,
3536
preloadedState,
36-
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(websocket),
37+
middleware: (getDefaultMiddleware) =>
38+
getDefaultMiddleware().concat(websocket).prepend(observer.middleware),
3739
});
3840

3941
store.subscribe(() => {

ui/frontend/observer.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { TypedStartListening, createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit';
2+
3+
import { AppDispatch } from './configureStore';
4+
import { State } from './reducers';
5+
import {
6+
allowLongRun,
7+
wsExecuteEnd,
8+
wsExecuteKill,
9+
wsExecuteStatus,
10+
} from './reducers/output/execute';
11+
import {
12+
currentExecutionSequenceNumberSelector,
13+
excessiveExecutionSelector,
14+
killGracePeriodMsSelector,
15+
} from './selectors';
16+
17+
export const observer = createListenerMiddleware();
18+
19+
type AppStartListening = TypedStartListening<State, AppDispatch>;
20+
const startAppListening = observer.startListening as AppStartListening;
21+
22+
// Watch for requests chewing up a lot of CPU and kill them unless the
23+
// user deliberately elects to keep them running.
24+
startAppListening({
25+
matcher: isAnyOf(wsExecuteStatus, allowLongRun, wsExecuteEnd),
26+
effect: async (_, listenerApi) => {
27+
// Just one listener at a time.
28+
listenerApi.unsubscribe();
29+
30+
await listenerApi.condition((_, state) => excessiveExecutionSelector(state));
31+
32+
// Ensure that we only act on the current execution, not whatever
33+
// is running later on.
34+
const state = listenerApi.getState();
35+
const gracePeriod = killGracePeriodMsSelector(state);
36+
const sequenceNumber = currentExecutionSequenceNumberSelector(state);
37+
38+
if (sequenceNumber) {
39+
const killed = listenerApi
40+
.delay(gracePeriod)
41+
.then(() => listenerApi.dispatch(wsExecuteKill(undefined, sequenceNumber)));
42+
43+
const allowed = listenerApi.condition((action) => allowLongRun.match(action));
44+
45+
const ended = listenerApi.condition(
46+
(action) => wsExecuteEnd.match(action) && action.meta.sequenceNumber === sequenceNumber,
47+
);
48+
49+
await Promise.race([killed, allowed, ended]);
50+
}
51+
52+
listenerApi.subscribe();
53+
},
54+
});

ui/frontend/reducers/globalConfiguration.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import * as z from 'zod';
44
const StateOverride = z.object({
55
baseUrl: z.string().optional(),
66
syncChangesToStorage: z.boolean().optional(),
7+
excessiveExecutionTimeS: z.number().optional(),
8+
killGracePeriodS: z.number().optional(),
79
});
810
type StateOverride = z.infer<typeof StateOverride>;
911

@@ -12,6 +14,8 @@ type State = Required<StateOverride>;
1214
const initialState: State = {
1315
baseUrl: '',
1416
syncChangesToStorage: true,
17+
excessiveExecutionTimeS: 15.0,
18+
killGracePeriodS: 15.0,
1519
};
1620

1721
const slice = createSlice({

ui/frontend/reducers/output/execute.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import * as z from 'zod';
33

44
import { ThunkAction } from '../../actions';
55
import { jsonPost, routes } from '../../api';
6-
import { executeRequestPayloadSelector, executeViaWebsocketSelector } from '../../selectors';
6+
import {
7+
currentExecutionSequenceNumberSelector,
8+
executeRequestPayloadSelector,
9+
executeViaWebsocketSelector,
10+
} from '../../selectors';
711
import { Channel, Edition, Mode } from '../../types';
812
import {
913
WsPayloadAction,
@@ -13,6 +17,7 @@ import {
1317

1418
const initialState: State = {
1519
requestsInProgress: 0,
20+
allowLongRun: false,
1621
};
1722

1823
interface State {
@@ -21,6 +26,9 @@ interface State {
2126
stdout?: string;
2227
stderr?: string;
2328
error?: string;
29+
residentSetSizeBytes?: number;
30+
totalTimeSecs?: number;
31+
allowLongRun: boolean;
2432
}
2533

2634
type wsExecuteRequestPayload = {
@@ -48,6 +56,14 @@ const { action: wsExecuteStderr, schema: wsExecuteStderrSchema } = createWebsock
4856
z.string(),
4957
);
5058

59+
const { action: wsExecuteStatus, schema: wsExecuteStatusSchema } = createWebsocketResponse(
60+
'output/execute/wsExecuteStatus',
61+
z.object({
62+
totalTimeSecs: z.number(),
63+
residentSetSizeBytes: z.number(),
64+
}),
65+
);
66+
5167
const { action: wsExecuteEnd, schema: wsExecuteEndSchema } = createWebsocketResponse(
5268
'output/execute/wsExecuteEnd',
5369
z.object({
@@ -134,6 +150,9 @@ const slice = createSlice({
134150

135151
prepare: prepareWithCurrentSequenceNumber,
136152
},
153+
allowLongRun: (state) => {
154+
state.allowLongRun = true;
155+
},
137156
},
138157
extraReducers: (builder) => {
139158
builder
@@ -163,6 +182,10 @@ const slice = createSlice({
163182
state.stdout = '';
164183
state.stderr = '';
165184
delete state.error;
185+
186+
delete state.residentSetSizeBytes;
187+
delete state.totalTimeSecs;
188+
state.allowLongRun = false;
166189
}),
167190
)
168191
.addCase(
@@ -177,6 +200,12 @@ const slice = createSlice({
177200
state.stderr += payload;
178201
}),
179202
)
203+
.addCase(
204+
wsExecuteStatus,
205+
sequenceNumberMatches((state, payload) => {
206+
Object.assign(state, payload);
207+
}),
208+
)
180209
.addCase(
181210
wsExecuteEnd,
182211
sequenceNumberMatches((state, payload) => {
@@ -191,7 +220,7 @@ const slice = createSlice({
191220
},
192221
});
193222

194-
export const { wsExecuteRequest } = slice.actions;
223+
export const { wsExecuteRequest, allowLongRun, wsExecuteKill } = slice.actions;
195224

196225
export const performCommonExecute =
197226
(crateType: string, tests: boolean): ThunkAction =>
@@ -211,7 +240,7 @@ const dispatchWhenSequenceNumber =
211240
<A extends UnknownAction>(cb: (sequenceNumber: number) => A): ThunkAction =>
212241
(dispatch, getState) => {
213242
const state = getState();
214-
const { sequenceNumber } = state.output.execute;
243+
const sequenceNumber = currentExecutionSequenceNumberSelector(state);
215244
if (sequenceNumber) {
216245
const action = cb(sequenceNumber);
217246
dispatch(action);
@@ -233,6 +262,14 @@ export const wsExecuteKillCurrent = (): ThunkAction =>
233262
slice.actions.wsExecuteKill(undefined, sequenceNumber),
234263
);
235264

236-
export { wsExecuteBeginSchema, wsExecuteStdoutSchema, wsExecuteStderrSchema, wsExecuteEndSchema };
265+
export {
266+
wsExecuteBeginSchema,
267+
wsExecuteStdoutSchema,
268+
wsExecuteStderrSchema,
269+
wsExecuteStatusSchema,
270+
wsExecuteEndSchema,
271+
};
272+
273+
export { wsExecuteStatus, wsExecuteEnd };
237274

238275
export default slice.reducer;

0 commit comments

Comments
 (0)