Skip to content

Commit

Permalink
fix: stop docker containers on application exit
Browse files Browse the repository at this point in the history
This commit introduces a fix that ensures docker containers
are gracefully stopped when the user exits the application.

fix: correct comment after mod in previous commit

fix: ipcRenderer import

fix(test): modify electron's mock to include ipcRenderer's .on and .removeAllListeners functions

feat: add shutting down indicator,
add test, and make app quit on all OS by removing "if"

test: add test for stopAll function

fix: call stopAll without passing any param. Immediately close app when no network is started

refactor: add test for stopAll dunction, moved 'shutting down...' label to en-US.json

refactor: move component to common folder. Move style to Styled mappings

refactor: removed an inline styling. Change key to reflect component's folder
  • Loading branch information
Abdullahi Yunus authored and jamaljsr committed Mar 25, 2024
1 parent 81cc097 commit 5cfacb2
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 2 deletions.
17 changes: 16 additions & 1 deletion electron/windowManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@ class WindowManager {
if (IS_DEV) {
await this.setupDevEnv();
}

this.mainWindow.on('close', e => {
e.preventDefault();
this.onMainWindowClose();
});
this.mainWindow.on('closed', this.onMainClosed);

ipcMain.on('docker-shut-down', this.onDockerContainerShutdown);

// use dev server for hot reload or file in production
this.mainWindow.loadURL(BASE_URL);

Expand Down Expand Up @@ -87,6 +92,16 @@ class WindowManager {
}
}

onMainWindowClose() {
if (this.mainWindow) {
this.mainWindow.webContents.send('app-closing');
}
}
onDockerContainerShutdown() {
this.mainWindow = null;
app.exit(0);
}

onActivate() {
if (this.mainWindow === null) {
this.createMainWindow();
Expand Down
2 changes: 2 additions & 0 deletions src/__mocks__/electron.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ module.exports = {
ipcRenderer: {
once: jest.fn(),
send: jest.fn(),
on: jest.fn(),
removeAllListeners: jest.fn(),
},
shell: {
openExternal: jest.fn(),
Expand Down
2 changes: 2 additions & 0 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ConnectedRouter } from 'connected-react-router';
import { StoreProvider } from 'easy-peasy';
import store, { hashHistory } from 'store';
import { Routes } from 'components/routing';
import DockerContainerShutdown from './common/DockerContainerShutdown';

const App: React.FC = () => {
useEffect(() => info('Rendering App component'), []);
Expand All @@ -18,6 +19,7 @@ const App: React.FC = () => {
<ConnectedRouter history={hashHistory}>
<Routes />
</ConnectedRouter>
<DockerContainerShutdown />
</Provider>
</StoreProvider>
);
Expand Down
27 changes: 27 additions & 0 deletions src/components/common/DockerContainerShutdown.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import { renderWithProviders } from 'utils/tests';
import DockerContainerShutdown from './DockerContainerShutdown';
import { ipcRenderer } from '__mocks__/electron';
import { waitFor } from '@testing-library/react';

describe('DockerContainerShutdown component', () => {
let unmount: () => void;
const renderComponent = async () => {
const cmp = <DockerContainerShutdown />;
const result = renderWithProviders(cmp);
unmount = result.unmount;
return result;
};
afterEach(() => unmount());
it('should not display overlay when shutting down', async () => {
const { queryByText } = await renderComponent();
expect(queryByText('Shutting down...')).toBeNull();
});

it('should set isShuttingDown to true when app is closing', async () => {
const { getByText } = await renderComponent();

ipcRenderer.on.mock.calls[0][1]();
await waitFor(() => expect(getByText('Shutting down...')).toBeInTheDocument());
});
});
50 changes: 50 additions & 0 deletions src/components/common/DockerContainerShutdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react';
import { ipcRenderer } from 'electron';
import { useStoreActions } from 'store';
import { useAsyncCallback } from 'react-async-hook';
import { Modal, Spin } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import { usePrefixedTranslation } from 'hooks';
import styled from '@emotion/styled';

const Styled = {
LoadingCard: styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 100%;
`,
LoadingLabel: styled.div`
margin-left: 10px;
`,
Loading: styled(LoadingOutlined)`
font-size: 24px;
`,
};

const DockerContainerShutdown: React.FC = () => {
const [isShuttingDown, setIsShuttingDown] = useState(false);
const { stopAll } = useStoreActions(s => s.network);
const stopDockerContainersAsync = useAsyncCallback(stopAll);
const { l } = usePrefixedTranslation('cmps.common.DockerContainerShutdown');
useEffect(() => {
ipcRenderer.on('app-closing', async () => {
setIsShuttingDown(true);
await stopDockerContainersAsync.execute();
});
return () => {
ipcRenderer.removeAllListeners('app-closing');
};
}, []);

return (
<Modal open={isShuttingDown} closable={false} maskClosable={false} footer={null}>
<Styled.LoadingCard>
<Spin size="large" indicator={<Styled.Loading spin />} />
<Styled.LoadingLabel>{l('shutdownMsg')}</Styled.LoadingLabel>
</Styled.LoadingCard>
</Modal>
);
};

export default DockerContainerShutdown;
1 change: 1 addition & 0 deletions src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"cmps.common.RemoveNode.invalidType": "Unknown node type '{{type}}'",
"cmps.common.RemoveNode.success": "The node {{name}} has been removed from the network",
"cmps.common.RemoveNode.error": "Unable to remove the node",
"cmps.common.DockerContainerShutdown.shutdownMsg": "Shutting down...",
"cmps.designer.bitcoind.BitcoinDetails.info": "Info",
"cmps.designer.bitcoind.BitcoinDetails.connect": "Connect",
"cmps.designer.bitcoind.BitcoinDetails.actions": "Actions",
Expand Down
52 changes: 52 additions & 0 deletions src/store/models/network.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as electron from 'electron';
import * as log from 'electron-log';
import { waitFor } from '@testing-library/react';
import detectPort from 'detect-port';
import { createStore } from 'easy-peasy';
import { NodeImplementation, Status, TapdNode } from 'shared/types';
import { AutoMineMode, CustomImage, Network } from 'types';
import * as asyncUtil from 'utils/async';
import { initChartFromNetwork } from 'utils/chart';
import { defaultRepoState } from 'utils/constants';
import * as files from 'utils/files';
Expand All @@ -26,13 +28,16 @@ jest.mock('utils/files', () => ({
waitForFile: jest.fn(),
rm: jest.fn(),
}));
jest.mock('utils/async');

const asyncUtilMock = asyncUtil as jest.Mocked<typeof asyncUtil>;
const filesMock = files as jest.Mocked<typeof files>;
const logMock = log as jest.Mocked<typeof log>;
const detectPortMock = detectPort as jest.Mock;
const bitcoindServiceMock = injections.bitcoindService as jest.Mocked<
typeof injections.bitcoindService
>;
const electronMock = electron as jest.Mocked<typeof electron>;

describe('Network model', () => {
const rootModel = {
Expand Down Expand Up @@ -695,6 +700,53 @@ describe('Network model', () => {
});
});

describe('Stop all', () => {
beforeEach(() => {
const { addNetwork } = store.getActions().network;
addNetwork(addNetworkArgs);
});

it('should shutdown immediately for stopped networks', async () => {
const { stopAll } = store.getActions().network;
await stopAll();
expect(electronMock.ipcRenderer.send).toHaveBeenCalledWith('docker-shut-down');
});

it('should stop the started networks', async () => {
const { stopAll, setStatus } = store.getActions().network;
setStatus({ id: firstNetwork().id, status: Status.Started });
await stopAll();
const { networks } = store.getState().network;
expect(networks.filter(n => n.status !== Status.Stopped)).toHaveLength(0);
expect(firstNetwork().status).toBe(Status.Stopped);
});

it('should handle a delay when stopping the networks', async () => {
jest.useFakeTimers();
asyncUtilMock.delay.mockResolvedValue(0);
const { stopAll, setStatus } = store.getActions().network;

setStatus({ id: firstNetwork().id, status: Status.Started });
await stopAll();
expect(setInterval).toHaveBeenCalledTimes(1);

// simulate the interval being called with Stopping nodes
setStatus({ id: firstNetwork().id, status: Status.Stopping });
jest.advanceTimersByTime(2000);
expect(electronMock.ipcRenderer.send).not.toHaveBeenCalled();

// simulate the interval being called with Stopped nodes
setStatus({ id: firstNetwork().id, status: Status.Stopped });
jest.advanceTimersByTime(2000);

// confirm the IPC message is sent
await waitFor(() => {
expect(electronMock.ipcRenderer.send).toHaveBeenCalledWith('docker-shut-down');
});
jest.useRealTimers();
});
});

describe('Toggle', () => {
beforeEach(() => {
const { addNetwork } = store.getActions().network;
Expand Down
23 changes: 22 additions & 1 deletion src/store/models/network.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { remote, SaveDialogOptions } from 'electron';
import { ipcRenderer, remote, SaveDialogOptions } from 'electron';
import { info } from 'electron-log';
import { join } from 'path';
import { push } from 'connected-react-router';
Expand Down Expand Up @@ -126,6 +126,7 @@ export interface NetworkModel {
>;
start: Thunk<NetworkModel, number, StoreInjections, RootModel, Promise<void>>;
stop: Thunk<NetworkModel, number, StoreInjections, RootModel, Promise<void>>;
stopAll: Thunk<NetworkModel, void, StoreInjections, RootModel, Promise<void>>;
toggle: Thunk<NetworkModel, number, StoreInjections, RootModel, Promise<void>>;
toggleNode: Thunk<NetworkModel, CommonNode, StoreInjections, RootModel, Promise<void>>;
monitorStartup: Thunk<
Expand Down Expand Up @@ -671,6 +672,26 @@ const networkModel: NetworkModel = {
throw e;
}
}),
stopAll: thunk(async (actions, _, { getState }) => {
let networks = getState().networks.filter(
n => n.status === Status.Started || n.status === Status.Stopping,
);
if (networks.length === 0) {
ipcRenderer.send('docker-shut-down');
}
networks.forEach(async network => {
await actions.stop(network.id);
});
setInterval(async () => {
networks = getState().networks.filter(
n => n.status === Status.Started || n.status === Status.Stopping,
);
if (networks.length === 0) {
await actions.save();
ipcRenderer.send('docker-shut-down');
}
}, 2000);
}),
toggle: thunk(async (actions, networkId, { getState }) => {
const network = getState().networks.find(n => n.id === networkId);
if (!network) throw new Error(l('networkByIdErr', { networkId }));
Expand Down

0 comments on commit 5cfacb2

Please sign in to comment.