Skip to content

Commit 629091d

Browse files
krisctlrashedmyt
authored andcommitted
Return distinct exit code for idle timeout shutdown
fixes #91
1 parent c69b39f commit 629091d

8 files changed

Lines changed: 278 additions & 37 deletions

File tree

gui/src/actionCreators/actionCreators.spec.js

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2020-2025 The MathWorks, Inc.
1+
// Copyright 2020-2026 The MathWorks, Inc.
22

33
import configureMockStore from 'redux-mock-store';
44
import thunk from 'redux-thunk';
@@ -452,4 +452,53 @@ describe('Test Async actionCreators', () => {
452452
);
453453
});
454454
});
455+
456+
it('should dispatch REQUEST_SHUTDOWN_INTEGRATION and RECEIVE_SHUTDOWN_INTEGRATION when shutting down without a reason', () => {
457+
fetchMock.deleteOnce('./shutdown_integration', {
458+
body: {
459+
matlab: {
460+
status: 'down'
461+
},
462+
licensing: null
463+
},
464+
headers: { 'content-type': 'application/json' }
465+
});
466+
467+
const expectedActionTypes = [
468+
actions.REQUEST_SHUTDOWN_INTEGRATION,
469+
actions.RECEIVE_SHUTDOWN_INTEGRATION
470+
];
471+
472+
return store.dispatch(actionCreators.fetchShutdownIntegration()).then(() => {
473+
const receivedActions = store.getActions();
474+
expect(receivedActions.map((action) => action.type)).toEqual(
475+
expectedActionTypes
476+
);
477+
});
478+
});
479+
480+
it('should dispatch REQUEST_SHUTDOWN_INTEGRATION and RECEIVE_SHUTDOWN_INTEGRATION with reason query param when reason is provided', () => {
481+
fetchMock.deleteOnce('./shutdown_integration?reason=IDLE_TIMEOUT', {
482+
body: {
483+
matlab: {
484+
status: 'down'
485+
},
486+
licensing: null
487+
},
488+
headers: { 'content-type': 'application/json' }
489+
});
490+
491+
const expectedActionTypes = [
492+
actions.REQUEST_SHUTDOWN_INTEGRATION,
493+
actions.RECEIVE_SHUTDOWN_INTEGRATION
494+
];
495+
496+
return store.dispatch(actionCreators.fetchShutdownIntegration({ reason: 'IDLE_TIMEOUT' })).then(() => {
497+
const receivedActions = store.getActions();
498+
expect(receivedActions.map((action) => action.type)).toEqual(
499+
expectedActionTypes
500+
);
501+
expect(fetchMock.lastUrl()).toBe('/shutdown_integration?reason=IDLE_TIMEOUT');
502+
});
503+
});
455504
});

gui/src/actionCreators/index.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2020-2025 The MathWorks, Inc.
1+
// Copyright 2020-2026 The MathWorks, Inc.
22

33
import {
44
SET_TRIGGER_POSITION,
@@ -374,7 +374,7 @@ export function fetchUnsetLicensing () {
374374
};
375375
}
376376

377-
export function fetchShutdownIntegration () {
377+
export function fetchShutdownIntegration ({ reason } = {}) {
378378
return async function (dispatch) {
379379
const options = {
380380
method: 'DELETE',
@@ -383,8 +383,12 @@ export function fetchShutdownIntegration () {
383383
credentials: 'same-origin'
384384
};
385385

386+
const url = reason
387+
? `./shutdown_integration?reason=${encodeURIComponent(reason)}`
388+
: './shutdown_integration';
389+
386390
dispatch(requestShutdownIntegration());
387-
const response = await fetchWithTimeout(dispatch, './shutdown_integration', options, 15000);
391+
const response = await fetchWithTimeout(dispatch, url, options, 15000);
388392
const data = await response.json();
389393
dispatch(receiveShutdownIntegration(data));
390394
};

gui/src/components/App/IdleBufferTimeouts.spec.jsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
// Copyright 2024-2025 The MathWorks, Inc.
1+
// Copyright 2024-2026 The MathWorks, Inc.
22

33
// File to test IDLE and BUFFER timeouts.
4-
// Need a seperate file for mocking BUFFER_TIMEOUT_DURATION before App component is imported for testing.
4+
// Need a separate file for mocking BUFFER_TIMEOUT_DURATION before App component is imported for testing.
55

66
import React from 'react';
77

@@ -73,7 +73,7 @@ describe('Timeouts in App Component', () => {
7373
});
7474

7575
// Mock fetchShutdownIntegration response
76-
fetchMock.deleteOnce('/shutdown_integration', {
76+
fetchMock.deleteOnce('/shutdown_integration?reason=IDLE_TIMEOUT', {
7777
body: createStatusResponse,
7878
headers: { 'content-type': 'application/json' }
7979
});
@@ -84,7 +84,7 @@ describe('Timeouts in App Component', () => {
8484

8585
await waitFor(() => {
8686
expect(fetchMock.called('get_status')).toBe(true);
87-
expect(fetchMock.called('shutdown_integration')).toBe(true);
87+
expect(fetchMock.called('shutdown_integration?reason=IDLE_TIMEOUT')).toBe(true);
8888
}, { timeout: initialState.idleTimeoutDuration * 1000 + mockBufferTimeoutDuration * 1000 + additionalTimeForFetchMock * 1000 });
8989
});
9090

gui/src/components/App/index.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2020-2025 The MathWorks, Inc.
1+
// Copyright 2020-2026 The MathWorks, Inc.
22

33
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
44
import { useSelector, useDispatch } from 'react-redux';
@@ -125,7 +125,7 @@ function App() {
125125
// BUFFER timer which runs for a BUFFER_TIMER_DURATION more seconds once the IDLE timer has expired to allow the ShutdownWarning
126126
// dialog box to appear on the screen, such that the user is informed of an impending termination.
127127
const [, bufferTimerCancel, bufferTimerReset] = useTimeoutFn(() => {
128-
dispatch(fetchShutdownIntegration());
128+
dispatch(fetchShutdownIntegration({ reason: 'IDLE_TIMEOUT' }));
129129
setBufferTimerHasExpired(true);
130130
}, BUFFER_TIMEOUT_DURATION * 1000);
131131

matlab_proxy/app.py

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import matlab_proxy
1717
from matlab_proxy import constants, settings, util
1818
from matlab_proxy.app_state import AppState
19-
from matlab_proxy.constants import IS_CONCURRENCY_CHECK_ENABLED
19+
from matlab_proxy.constants import IS_CONCURRENCY_CHECK_ENABLED, ExitReason
2020
from matlab_proxy.util import mwi
2121
from matlab_proxy.util.mwi import download, token_auth
2222
from matlab_proxy.util.mwi import environment_variables as mwi_env
@@ -444,6 +444,11 @@ async def shutdown_integration_delete(req):
444444

445445
logger.info(f"Shutting down {state.settings['integration_name']}...")
446446
state.is_shutting_down = True
447+
448+
try:
449+
state.exit_reason = ExitReason[req.query.get("reason", "").upper()]
450+
except KeyError:
451+
pass
447452
res = create_status_response(req.app, "../")
448453

449454
# Schedule the shutdown to happen after the response is sent
@@ -1008,41 +1013,56 @@ def create_and_start_app(config_name):
10081013
Args:
10091014
config_name (str): Name of the configuration to use with matlab-proxy.
10101015
"""
1011-
util.system.configure_no_proxy_in_env(logger)
1016+
exit_code = int(ExitReason.UNEXPECTED_ERROR)
10121017

1013-
# Create, configure and start the app.
1014-
app = create_app(config_name)
1015-
app = configure_and_start(app)
1018+
try:
1019+
util.system.configure_no_proxy_in_env(logger)
10161020

1017-
loop = util.get_event_loop()
1021+
# Create, configure and start the app.
1022+
app = create_app(config_name)
1023+
app = configure_and_start(app)
10181024

1019-
# Add signal handlers for the current python process
1020-
loop = util.add_signal_handlers(loop)
1021-
try:
1022-
# Further execution is stopped here until an interrupt is raised
1023-
loop.run_forever()
1025+
loop = util.get_event_loop()
10241026

1025-
except SystemExit:
1026-
pass
1027+
# Add signal handlers for the current python process
1028+
loop = util.add_signal_handlers(loop)
1029+
try:
1030+
# Further execution is stopped here until an interrupt is raised
1031+
loop.run_forever()
10271032

1028-
# After handling the interrupt, proceed with shutting down the server gracefully.
1029-
try:
1030-
# aiohttp shutdown to be invoked before cleanup -
1031-
# https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.Application.shutdown
1032-
loop.run_until_complete(app.shutdown())
1033-
loop.run_until_complete(app.cleanup())
1033+
except SystemExit:
1034+
pass
10341035

1035-
running_tasks = asyncio.all_tasks(loop)
1036+
# After handling the interrupt, proceed with shutting down the server gracefully.
1037+
try:
1038+
# aiohttp shutdown to be invoked before cleanup -
1039+
# https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.Application.shutdown
1040+
loop.run_until_complete(app.shutdown())
1041+
loop.run_until_complete(app.cleanup())
1042+
1043+
running_tasks = asyncio.all_tasks(loop)
1044+
1045+
# Gracefully cancel all running background tasks
1046+
loop.run_until_complete(util.cancel_tasks(running_tasks))
1047+
1048+
except Exception:
1049+
pass
10361050

1037-
# Gracefully cancel all running background tasks
1038-
loop.run_until_complete(util.cancel_tasks(running_tasks))
1051+
state = app["state"]
1052+
logger.info(
1053+
f"Finished shutting down (reason: {state.exit_reason.name}, "
1054+
f"code: {int(state.exit_reason)}). Thank you for using the MATLAB proxy."
1055+
)
1056+
loop.close()
1057+
exit_code = int(state.exit_reason)
1058+
1059+
except SystemExit:
1060+
logger.exception("Unexpected SystemExit raised during execution.")
10391061

10401062
except Exception:
1041-
pass
1063+
logger.exception("Unexpected error caused shutdown.")
10421064

1043-
logger.info("Finished shutting down. Thank you for using the MATLAB proxy.")
1044-
loop.close()
1045-
sys.exit(0)
1065+
sys.exit(exit_code)
10461066

10471067

10481068
def print_version_and_exit():

matlab_proxy/app_state.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
IS_CONCURRENCY_CHECK_ENABLED,
2020
MATLAB_LOGS_FILE_NAME,
2121
USER_CODE_OUTPUT_FILE_NAME,
22+
ExitReason,
2223
)
2324
from matlab_proxy.settings import get_process_startup_timeout
2425
from matlab_proxy.util import mw, mwi, system, windows
@@ -154,6 +155,7 @@ def __init__(self, settings):
154155

155156
# Flag to track if matlab-proxy is in the process of shutting down
156157
self.is_shutting_down: bool = False
158+
self.exit_reason: ExitReason = ExitReason.NORMAL_SHUTDOWN
157159

158160
def set_remaining_idle_timeout(self, new_timeout):
159161
"""Sets the remaining IDLE timeout after the validating checks.
@@ -220,6 +222,7 @@ async def __decrement_idle_timer(self):
220222

221223
logger.info("The IDLE timer for shutdown has run out...")
222224
logger.info(f"Shutting down {self.settings['integration_name']}")
225+
self.exit_reason = ExitReason.IDLE_TIMEOUT
223226
await self.stop_matlab()
224227
loop = util.get_event_loop()
225228
loop.stop()
@@ -1377,7 +1380,6 @@ async def start_matlab(self, restart_matlab=False):
13771380
await self.__start_window_manager(display)
13781381

13791382
try:
1380-
13811383
# Prepare ready file for the MATLAB process.
13821384
self.create_logs_dir_for_MATLAB()
13831385

matlab_proxy/constants.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Copyright 2023-2026 The MathWorks, Inc.
2+
from enum import IntEnum
23
from typing import Final, List
34

45
"""This module defines project-level constants"""
@@ -36,3 +37,16 @@
3637

3738
# Interval in seconds to wait before querying the status of MATLAB.
3839
CHECK_MATLAB_STATUS_INTERVAL_SECONDS: Final[int] = 1
40+
41+
42+
class ExitReason(IntEnum):
43+
"""Exit reasons for matlab-proxy with distinct exit codes.
44+
45+
Codes 100+ are application-defined to avoid collision with standard Unix exit codes.
46+
Code 0 is retained for normal signal-based shutdown.
47+
Code 1 is used for unexpected errors to distinguish from intentional application exits.
48+
"""
49+
50+
NORMAL_SHUTDOWN = 0
51+
UNEXPECTED_ERROR = 1
52+
IDLE_TIMEOUT = 100

0 commit comments

Comments
 (0)