Skip to content

Commit

Permalink
Nudge the user to kill programs using excessive CPU
Browse files Browse the repository at this point in the history
  • Loading branch information
shepmaster committed Dec 14, 2023
1 parent f9824eb commit 618740b
Show file tree
Hide file tree
Showing 13 changed files with 280 additions and 6 deletions.
64 changes: 64 additions & 0 deletions tests/spec/features/excessive_execution_spec.rb
@@ -0,0 +1,64 @@
require 'json'

require 'spec_helper'
require 'support/editor'
require 'support/playground_actions'

RSpec.feature "Excessive executions", type: :feature, js: true do
include PlaygroundActions

before do
visit "/?#{config_overrides}"
editor.set(code)
end

scenario "a notification is shown" do
within(:header) { click_on("Run") }
within(:notification, text: 'will be automatically killed') do
expect(page).to have_button 'Kill the process now'
expect(page).to have_button 'Allow the process to continue'
end
end

scenario "the process is automatically killed if nothing is done" do
within(:header) { click_on("Run") }
expect(page).to have_selector(:notification, text: 'will be automatically killed', wait: 2)
expect(page).to_not have_selector(:notification, text: 'will be automatically killed', wait: 4)
expect(page).to have_content("Exited with signal 9")
end

scenario "the process can continue running" do
within(:header) { click_on("Run") }
within(:notification, text: 'will be automatically killed') do
click_on 'Allow the process to continue'
end
within(:output, :stdout) do
expect(page).to have_content("Exited normally")
end
end

def editor
Editor.new(page)
end

def code
<<~EOF
use std::time::{Duration, Instant};
fn main() {
let start = Instant::now();
while start.elapsed() < Duration::from_secs(5) {}
println!("Exited normally");
}
EOF
end

def config_overrides
config = {
killGracePeriodS: 3.0,
excessiveExecutionTimeS: 0.5,
}

"whte_rbt.obj=#{config.to_json}"
end
end
4 changes: 4 additions & 0 deletions tests/spec/spec_helper.rb
Expand Up @@ -83,6 +83,10 @@
css { '[data-test-id = "stdin"]' }
end

Capybara.add_selector(:notification) do
css { '[data-test-id = "notification"]' }
end

RSpec.configure do |config|
config.after(:example, :js) do
page.execute_script <<~JS
Expand Down
1 change: 1 addition & 0 deletions ui/frontend/.eslintrc.js
Expand Up @@ -86,6 +86,7 @@ module.exports = {
'editor/AceEditor.tsx',
'editor/SimpleEditor.tsx',
'hooks.ts',
'observer.ts',
'reducers/browser.ts',
'reducers/client.ts',
'reducers/code.ts',
Expand Down
1 change: 1 addition & 0 deletions ui/frontend/.prettierignore
Expand Up @@ -26,6 +26,7 @@ node_modules
!editor/AceEditor.tsx
!editor/SimpleEditor.tsx
!hooks.ts
!observer.ts
!reducers/browser.ts
!reducers/client.ts
!reducers/code.ts
Expand Down
7 changes: 7 additions & 0 deletions ui/frontend/Notifications.module.css
Expand Up @@ -28,3 +28,10 @@ $space: 0.25em;
background: #e1e1db;
padding: $space;
}

.action {
display: flex;
justify-content: center;
padding-top: 0.5em;
gap: 0.5em;
}
27 changes: 26 additions & 1 deletion ui/frontend/Notifications.tsx
Expand Up @@ -4,6 +4,7 @@ import { Portal } from 'react-portal';
import { Close } from './Icon';
import { useAppDispatch, useAppSelector } from './hooks';
import { seenRustSurvey2022 } from './reducers/notifications';
import { allowLongRun, wsExecuteKillCurrent } from './reducers/output/execute';
import * as selectors from './selectors';

import styles from './Notifications.module.css';
Expand All @@ -15,6 +16,7 @@ const Notifications: React.FC = () => {
<Portal>
<div className={styles.container}>
<RustSurvey2022Notification />
<ExcessiveExecutionNotification />
</div>
</Portal>
);
Expand All @@ -36,13 +38,36 @@ const RustSurvey2022Notification: React.FC = () => {
) : null;
};

const ExcessiveExecutionNotification: React.FC = () => {
const showExcessiveExecution = useAppSelector(selectors.excessiveExecutionSelector);
const time = useAppSelector(selectors.excessiveExecutionTimeSelector);
const gracePeriod = useAppSelector(selectors.killGracePeriodTimeSelector);

const dispatch = useAppDispatch();
const allow = useCallback(() => dispatch(allowLongRun()), [dispatch]);
const kill = useCallback(() => dispatch(wsExecuteKillCurrent()), [dispatch]);

return showExcessiveExecution ? (
<Notification onClose={allow}>
The running process has used more than {time} of CPU time. This is often caused by an error in
the code. As the playground is a shared resource, the process will be automatically killed in{' '}
{gracePeriod}. You can always kill the process manually via the menu at the bottom of the
screen.
<div className={styles.action}>
<button onClick={kill}>Kill the process now</button>
<button onClick={allow}>Allow the process to continue</button>
</div>
</Notification>
) : null;
};

interface NotificationProps {
children: React.ReactNode;
onClose: () => void;
}

const Notification: React.FC<NotificationProps> = ({ onClose, children }) => (
<div className={styles.notification}>
<div className={styles.notification} data-test-id="notification">
<div className={styles.notificationContent}>{children}</div>
<button className={styles.close} onClick={onClose}>
<Close />
Expand Down
4 changes: 3 additions & 1 deletion ui/frontend/configureStore.ts
Expand Up @@ -3,6 +3,7 @@ import { produce } from 'immer';
import { merge } from 'lodash-es';

import initializeLocalStorage from './local_storage';
import { observer } from './observer';
import reducer from './reducers';
import initializeSessionStorage from './session_storage';
import { websocketMiddleware } from './websocketMiddleware';
Expand Down Expand Up @@ -33,7 +34,8 @@ export default function configureStore(window: Window) {
const store = reduxConfigureStore({
reducer,
preloadedState,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(websocket),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(websocket).prepend(observer.middleware),
});

store.subscribe(() => {
Expand Down
54 changes: 54 additions & 0 deletions ui/frontend/observer.ts
@@ -0,0 +1,54 @@
import { TypedStartListening, createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit';

import { AppDispatch } from './configureStore';
import { State } from './reducers';
import {
allowLongRun,
wsExecuteEnd,
wsExecuteKill,
wsExecuteStatus,
} from './reducers/output/execute';
import {
currentExecutionSequenceNumberSelector,
excessiveExecutionSelector,
killGracePeriodMsSelector,
} from './selectors';

export const observer = createListenerMiddleware();

type AppStartListening = TypedStartListening<State, AppDispatch>;
const startAppListening = observer.startListening as AppStartListening;

// Watch for requests chewing up a lot of CPU and kill them unless the
// user deliberately elects to keep them running.
startAppListening({
matcher: isAnyOf(wsExecuteStatus, allowLongRun, wsExecuteEnd),
effect: async (_, listenerApi) => {
// Just one listener at a time.
listenerApi.unsubscribe();

await listenerApi.condition((_, state) => excessiveExecutionSelector(state));

// Ensure that we only act on the current execution, not whatever
// is running later on.
const state = listenerApi.getState();
const gracePeriod = killGracePeriodMsSelector(state);
const sequenceNumber = currentExecutionSequenceNumberSelector(state);

if (sequenceNumber) {
const killed = listenerApi
.delay(gracePeriod)
.then(() => listenerApi.dispatch(wsExecuteKill(undefined, sequenceNumber)));

const allowed = listenerApi.condition((action) => allowLongRun.match(action));

const ended = listenerApi.condition(
(action) => wsExecuteEnd.match(action) && action.meta.sequenceNumber === sequenceNumber,
);

await Promise.race([killed, allowed, ended]);
}

listenerApi.subscribe();
},
});
4 changes: 4 additions & 0 deletions ui/frontend/reducers/globalConfiguration.ts
Expand Up @@ -4,6 +4,8 @@ import * as z from 'zod';
const StateOverride = z.object({
baseUrl: z.string().optional(),
syncChangesToStorage: z.boolean().optional(),
excessiveExecutionTimeS: z.number().optional(),
killGracePeriodS: z.number().optional(),
});
type StateOverride = z.infer<typeof StateOverride>;

Expand All @@ -12,6 +14,8 @@ type State = Required<StateOverride>;
const initialState: State = {
baseUrl: '',
syncChangesToStorage: true,
excessiveExecutionTimeS: 15.0,
killGracePeriodS: 15.0,
};

const slice = createSlice({
Expand Down
45 changes: 41 additions & 4 deletions ui/frontend/reducers/output/execute.ts
Expand Up @@ -3,7 +3,11 @@ import * as z from 'zod';

import { ThunkAction } from '../../actions';
import { jsonPost, routes } from '../../api';
import { executeRequestPayloadSelector, executeViaWebsocketSelector } from '../../selectors';
import {
currentExecutionSequenceNumberSelector,
executeRequestPayloadSelector,
executeViaWebsocketSelector,
} from '../../selectors';
import { Channel, Edition, Mode } from '../../types';
import {
WsPayloadAction,
Expand All @@ -13,6 +17,7 @@ import {

const initialState: State = {
requestsInProgress: 0,
allowLongRun: false,
};

interface State {
Expand All @@ -21,6 +26,9 @@ interface State {
stdout?: string;
stderr?: string;
error?: string;
residentSetSizeBytes?: number;
totalTimeSecs?: number;
allowLongRun: boolean;
}

type wsExecuteRequestPayload = {
Expand Down Expand Up @@ -48,6 +56,14 @@ const { action: wsExecuteStderr, schema: wsExecuteStderrSchema } = createWebsock
z.string(),
);

const { action: wsExecuteStatus, schema: wsExecuteStatusSchema } = createWebsocketResponse(
'output/execute/wsExecuteStatus',
z.object({
totalTimeSecs: z.number(),
residentSetSizeBytes: z.number(),
}),
);

const { action: wsExecuteEnd, schema: wsExecuteEndSchema } = createWebsocketResponse(
'output/execute/wsExecuteEnd',
z.object({
Expand Down Expand Up @@ -134,6 +150,9 @@ const slice = createSlice({

prepare: prepareWithCurrentSequenceNumber,
},
allowLongRun: (state) => {
state.allowLongRun = true;
},
},
extraReducers: (builder) => {
builder
Expand Down Expand Up @@ -163,6 +182,10 @@ const slice = createSlice({
state.stdout = '';
state.stderr = '';
delete state.error;

delete state.residentSetSizeBytes;
delete state.totalTimeSecs;
state.allowLongRun = false;
}),
)
.addCase(
Expand All @@ -177,6 +200,12 @@ const slice = createSlice({
state.stderr += payload;
}),
)
.addCase(
wsExecuteStatus,
sequenceNumberMatches((state, payload) => {
Object.assign(state, payload);
}),
)
.addCase(
wsExecuteEnd,
sequenceNumberMatches((state, payload) => {
Expand All @@ -191,7 +220,7 @@ const slice = createSlice({
},
});

export const { wsExecuteRequest } = slice.actions;
export const { wsExecuteRequest, allowLongRun, wsExecuteKill } = slice.actions;

export const performCommonExecute =
(crateType: string, tests: boolean): ThunkAction =>
Expand All @@ -211,7 +240,7 @@ const dispatchWhenSequenceNumber =
<A extends UnknownAction>(cb: (sequenceNumber: number) => A): ThunkAction =>
(dispatch, getState) => {
const state = getState();
const { sequenceNumber } = state.output.execute;
const sequenceNumber = currentExecutionSequenceNumberSelector(state);
if (sequenceNumber) {
const action = cb(sequenceNumber);
dispatch(action);
Expand All @@ -233,6 +262,14 @@ export const wsExecuteKillCurrent = (): ThunkAction =>
slice.actions.wsExecuteKill(undefined, sequenceNumber),
);

export { wsExecuteBeginSchema, wsExecuteStdoutSchema, wsExecuteStderrSchema, wsExecuteEndSchema };
export {
wsExecuteBeginSchema,
wsExecuteStdoutSchema,
wsExecuteStderrSchema,
wsExecuteStatusSchema,
wsExecuteEndSchema,
};

export { wsExecuteStatus, wsExecuteEnd };

export default slice.reducer;

0 comments on commit 618740b

Please sign in to comment.