Skip to content

Commit 4cb6286

Browse files
committed
Address (AI) review comments
- E2E: set up watching for dashboard open before trying to open it. - Steve: don't record the port until everything is ready. - Steve: set a timeout for port being ready. - Steve: guard against concurrent calls to `start()`. - Steve: track running by actually looking at the process. - Steve: always clear port on stop (even if it's not running). - Dashboard UI: get steve port via IPC. - Dashboard UI: don't enable if steve port is unset. - Dashboard UI: don't open if there is no port. - Dashboard UI: open explorer view directly. - Vuex store: plugins use the root store. - Dependencies: ignore files that definitely not a steve archive. Signed-off-by: Mark Yen <mark.yen@suse.com>
1 parent 17ba826 commit 4cb6286

11 files changed

Lines changed: 172 additions & 50 deletions

File tree

e2e/dashboard.e2e.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,19 @@ test.describe.serial('Dashboard', () => {
1717
});
1818

1919
test.afterAll(({ colorScheme }, testInfo) => teardown(electronApp, testInfo));
20+
test.afterAll(() => dashboardPage?.close()?.catch(() => { /* ignore */ }));
21+
test.afterAll(() => kubectl('delete', '--ignore-not-found', 'pod', 'e2e-nginx').catch(() => { /* ignore */ }));
2022

2123
test('should allow opening the dashboard', async() => {
2224
const navPage = new NavPage(page);
2325

2426
await navPage.progressBecomesReady();
27+
28+
// Set up the promise before clicking, to ensure we don't miss the event.
29+
const dashboardPromise = electronApp.waitForEvent('window', page => page.url().includes('/c/local/explorer'));
2530
await navPage.dashboardButton.click();
2631

27-
dashboardPage = await electronApp.waitForEvent('window', page => page.url().includes('/c/local/explorer'));
32+
dashboardPage = await dashboardPromise;
2833
});
2934

3035
test('kubernetes should be available', async() => {
@@ -71,9 +76,4 @@ test.describe.serial('Dashboard', () => {
7176
const logs = windowManager.locator('*[id="panel-default/e2e-nginx-logs"] .logs-container');
7277
await expect(logs).toContainText('docker-entrypoint.sh');
7378
});
74-
75-
test('cleanup', async() => {
76-
await dashboardPage.close();
77-
await kubectl('delete', 'pod', 'e2e-nginx');
78-
});
7979
});

e2e/extensions.e2e.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ test.describe.serial('Extensions', () => {
457457
await expect(result).resolves.toContain(contents);
458458
});
459459
} finally {
460-
server.close();
460+
await new Promise<Error | void>(resolve => server.close(resolve));
461461
}
462462
});
463463
test.describe('can post values', () => {

pkg/rancher-desktop/backend/steve.ts

Lines changed: 93 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { ChildProcess, spawn } from 'child_process';
22
import os from 'os';
33
import path from 'path';
4-
import { setTimeout } from 'timers/promises';
54

65
import Electron from 'electron';
76

87
import K3sHelper from '@pkg/backend/k3sHelper';
8+
import { getIpcMainProxy } from '@pkg/main/ipcMain';
99
import Latch from '@pkg/utils/latch';
1010
import Logging from '@pkg/utils/logging';
1111
import paths from '@pkg/utils/paths';
12+
import { send } from '@pkg/window';
1213

1314
const console = Logging.steve;
15+
const ipcMainProxy = getIpcMainProxy(console);
1416

1517
/**
1618
* @description Singleton that manages the lifecycle of the Steve API.
@@ -19,11 +21,17 @@ export class Steve {
1921
private static instance: Steve;
2022
private process: ChildProcess | undefined;
2123

22-
private isRunning: boolean;
24+
// Promise to prevent multiple simultaneous calls to start() from causing
25+
// multiple instances of Steve from being created.
26+
private pendingStart: Promise<number> | undefined;
27+
2328
#port = 0;
2429

2530
private constructor() {
26-
this.isRunning = false;
31+
send('steve-port', 0);
32+
ipcMainProxy.on('steve-port', () => {
33+
send('steve-port', this.port);
34+
});
2735
}
2836

2937
/**
@@ -42,14 +50,34 @@ export class Steve {
4250
* @description Starts the Steve API if one is not already running.
4351
* Returns only after Steve is ready to accept connections.
4452
* @returns The port Steve is listening on.
53+
* @note Concurrent calls are serialized.
4554
*/
4655
public async start(): Promise<number> {
47-
const { pid } = this.process || { };
56+
// Prevent multiple concurrent calls to start().
57+
const promise = this.pendingStart || this.startInternal();
58+
this.pendingStart = promise;
59+
try {
60+
return await promise;
61+
} finally {
62+
this.pendingStart = undefined;
63+
}
64+
}
4865

49-
if (this.isRunning && pid) {
50-
console.debug(`Steve is already running with pid: ${ pid }`);
66+
/**
67+
* This is the implementation of `start()`; it should always be called via
68+
* `start()`, as it does not guard against concurrent calls.
69+
*/
70+
private async startInternal(): Promise<number> {
71+
if (this.isRunning) {
72+
if (this.port) {
73+
console.debug(`Steve is already running with port: ${ this.port }`);
5174

52-
return this.#port;
75+
return this.port;
76+
}
77+
// If the process is running, but we don't have a port, suspect that Steve
78+
// is in a bad state and restart it.
79+
console.warn(`Steve process is running without a port. Restarting...`);
80+
this.stop();
5381
}
5482

5583
const osSpecificName = /^win/i.test(os.platform()) ? 'steve.exe' : 'steve';
@@ -62,10 +90,11 @@ export class Steve {
6290
// do nothing
6391
}
6492
console.debug(`Starting Steve with KUBECONFIG=${ env.KUBECONFIG }`);
65-
this.process = spawn(stevePath, ['--context', 'rancher-desktop'],
93+
const childProcess = spawn(stevePath, ['--context', 'rancher-desktop'],
6694
{ env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
95+
this.process = childProcess;
6796

68-
const { stdout, stderr } = this.process;
97+
const { stdout, stderr } = childProcess;
6998
let portBuffer = '';
7099
const portLatch = Latch<number>();
71100

@@ -75,7 +104,7 @@ export class Steve {
75104
throw new Error(`Failed to start Steve: could not get output`);
76105
}
77106

78-
// Steve has been modified to output the port to stdout and then immediate
107+
// Steve has been modified to output the port to stdout and then immediately
79108
// close it, leaving stderr open for logs.
80109
console.debug('Waiting for Steve to output port...');
81110
stdout.on('data', (data) => {
@@ -90,33 +119,70 @@ export class Steve {
90119
}
91120
});
92121

122+
// Set up a handler for the port latch erroring in case we never get to the
123+
// point of waiting for it.
124+
portLatch.catch((err) => {
125+
console.error(err);
126+
try {
127+
// Kill the child process if it's still alive.
128+
childProcess.kill();
129+
} catch { /* ignore */ }
130+
});
131+
93132
stderr.on('data', (data) => {
94133
console.error(`stderr: ${ data }`);
95134
});
96135

97-
this.process.on('exit', (code, signal) => {
98-
console.log(`child process exited with code ${ code } and signal ${ signal }`);
99-
this.isRunning = false;
136+
childProcess.on('exit', (code, signal) => {
137+
if (childProcess !== this.process) {
138+
// A stale process has exited; ignore.
139+
console.debug(`Stale steve process exited with code ${ code } and signal ${ signal }`);
140+
return;
141+
}
142+
console.log(`Steve process exited with code ${ code } and signal ${ signal }`);
143+
this.#port = 0;
144+
send('steve-port', 0);
100145
portLatch.reject(new Error(`Steve process exited unexpectedly with code ${ code } and signal ${ signal }`));
101146
});
102147

103148
await new Promise<void>((resolve, reject) => {
104-
this.process?.once('spawn', () => {
105-
this.isRunning = true;
106-
console.debug(`Spawned child pid: ${ this.process?.pid }`);
149+
const timeout = setTimeout(() => {
150+
const error = new Error('Timed out waiting for Steve to start');
151+
portLatch.reject(error); // Kills the child process.
152+
reject(error);
153+
}, 10_000);
154+
childProcess.once('spawn', () => {
155+
clearTimeout(timeout);
156+
console.debug(`Spawned child pid: ${ childProcess.pid }`);
107157
resolve();
108158
});
109-
this.process?.once('error', (err) => {
159+
childProcess.once('error', (err) => {
160+
clearTimeout(timeout);
110161
reject(new Error(`Failed to spawn Steve: ${ err.message }`, { cause: err }));
111162
});
112-
setTimeout(10_000).then(() => reject(new Error('Timed out waiting for Steve to start')));
113163
});
114-
this.#port = await portLatch;
115-
console.debug(`Steve is listening on port: ${ this.#port }`);
164+
// Set a timeout in case Steve fails to listen to a port.
165+
const portTimeout = setTimeout(() => {
166+
portLatch.reject(new Error('Timed out waiting for Steve port'));
167+
}, 30_000);
168+
try {
169+
const port = await portLatch;
170+
console.debug(`Steve is listening on port: ${ port }`);
116171

117-
await this.waitForReady(this.#port);
172+
await this.waitForReady(port);
173+
this.#port = port;
174+
send('steve-port', port);
118175

119-
return this.#port;
176+
return port;
177+
} finally {
178+
clearTimeout(portTimeout);
179+
}
180+
}
181+
182+
private get isRunning() {
183+
const { pid, exitCode, signalCode } = this.process || { };
184+
185+
return !!pid && exitCode === null && signalCode === null;
120186
}
121187

122188
public get port() {
@@ -141,7 +207,7 @@ export class Steve {
141207
return;
142208
}
143209

144-
await setTimeout(delayMs);
210+
await new Promise(resolve => setTimeout(resolve, delayMs));
145211
}
146212

147213
throw new Error(`Steve did not become ready after ${ maxAttempts * delayMs / 1000 } seconds`);
@@ -172,11 +238,10 @@ export class Steve {
172238
* Stops the Steve API.
173239
*/
174240
public stop() {
175-
if (!this.isRunning) {
176-
return;
177-
}
178-
179-
this.process?.kill('SIGINT');
180241
this.#port = 0;
242+
send('steve-port', 0);
243+
if (this.isRunning) {
244+
this.process?.kill('SIGINT');
245+
}
181246
}
182247
}

pkg/rancher-desktop/components/DashboardOpen.vue

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
<script lang="ts">
22
import { defineComponent } from 'vue';
3-
import { mapGetters } from 'vuex';
43
54
import { State as K8sState } from '@pkg/backend/backend';
5+
import { mapTypedGetters, mapTypedState } from '@pkg/entry/store';
66
77
export default defineComponent({
88
name: 'dashboard-open',
99
computed: {
10-
...mapGetters('preferences', ['getPreferences']),
11-
...mapGetters('k8sManager', { k8sState: 'getK8sState' }),
10+
...mapTypedGetters('preferences', ['getPreferences']),
11+
...mapTypedGetters('k8sManager', { k8sState: 'getK8sState' }),
12+
...mapTypedState('steve', ['port']),
1213
kubernetesEnabled(): boolean {
1314
return this.getPreferences.kubernetes.enabled;
1415
},
15-
kubernetesStarted(): boolean {
16-
return this.k8sState === K8sState.STARTED;
16+
dashboardReady(): boolean {
17+
return this.k8sState === K8sState.STARTED && this.port > 0;
1718
},
1819
},
1920
methods: {
@@ -27,7 +28,7 @@ export default defineComponent({
2728
<template>
2829
<button
2930
v-if="kubernetesEnabled"
30-
:disabled="!kubernetesStarted"
31+
:disabled="!dashboardReady"
3132
class="btn role-secondary btn-icon-text"
3233
@click="openDashboard"
3334
>

pkg/rancher-desktop/entry/store.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as Preferences from '../store/preferences';
1414
import * as Prefs from '../store/prefs';
1515
import * as ResourceFetch from '../store/resource-fetch';
1616
import * as Snapshots from '../store/snapshots';
17+
import * as Steve from '../store/steve';
1718
import * as TransientSettings from '../store/transientSettings';
1819

1920
const modules = {
@@ -31,16 +32,21 @@ const modules = {
3132
prefs: Prefs,
3233
'resource-fetch': ResourceFetch,
3334
snapshots: Snapshots,
35+
steve: Steve,
3436
transientSettings: TransientSettings,
3537
};
3638

39+
export type Modules = typeof modules;
40+
41+
export type RootState = {
42+
[K in keyof Modules]: ReturnType<Modules[K]['state']>;
43+
};
44+
3745
export default createStore<any>({
3846
modules: Object.fromEntries(Object.entries(modules).map(([k, v]) => [k, { namespaced: true, ...v }])),
39-
plugins: Object.values(modules).flatMap(v => 'plugins' in v ? v.plugins : []),
47+
plugins: Object.values(modules).flatMap<Plugin<RootState>>(v => 'plugins' in v ? v.plugins : []),
4048
});
4149

42-
export type Modules = typeof modules;
43-
4450
/**
4551
* mapTypedGetters is a wrapper around mapGetters that is aware of the types of
4652
* the Vuex stores we have availabile, and returns the correctly typed values.

pkg/rancher-desktop/store/diagnostics.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Plugin } from 'vuex';
66
import { ActionTree, MutationsType } from './ts-helpers';
77

88
import { CURRENT_SETTINGS_VERSION } from '@pkg/config/settings';
9+
import { RootState } from '@pkg/entry/store';
910
import type { DiagnosticsResult, DiagnosticsResultCollection } from '@pkg/main/diagnostics/diagnostics';
1011
import ipcRenderer from '@pkg/utils/ipcRenderer';
1112

@@ -165,7 +166,7 @@ export const actions = {
165166
},
166167
} satisfies ActionTree<DiagnosticsState, any, typeof mutations>;
167168

168-
export const plugins: Plugin<DiagnosticsState>[] = [
169+
export const plugins: Plugin<RootState>[] = [
169170
// Vuex plugin used to refresh diagnostics on command from the backend.
170171
function(store) {
171172
ipcRenderer.on('diagnostics/update', () => {

pkg/rancher-desktop/store/steve.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Plugin } from 'vuex';
2+
3+
import { MutationsType } from './ts-helpers';
4+
5+
import { RootState } from '@pkg/entry/store';
6+
import ipcRenderer from '@pkg/utils/ipcRenderer';
7+
8+
interface SteveState {
9+
port: number;
10+
}
11+
12+
export const state: () => SteveState = () => (
13+
{
14+
port: 0,
15+
}
16+
);
17+
18+
export const mutations = {
19+
SET_PORT(state, port) {
20+
state.port = port;
21+
},
22+
} satisfies MutationsType<SteveState>;
23+
24+
export const plugins: Plugin<RootState>[] = [
25+
// Vuex plugin to monitor for Steve port updates.
26+
function({ commit }) {
27+
ipcRenderer.on('steve-port', (event, port) => {
28+
commit('steve/SET_PORT', port);
29+
});
30+
ipcRenderer.send('steve-port');
31+
},
32+
];

pkg/rancher-desktop/typings/electron-ipc.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface IpcMainEvents {
2626
'k8s-integration-set': (name: string, newState: boolean) => void;
2727
'factory-reset': (keepSystemImages: boolean) => void;
2828
'update-network-status': (status: boolean) => void;
29+
/** Trigger the corresponding IPC renderer event. */
30+
'steve-port': () => void;
2931

3032
// #region main/update
3133
'update-state': () => void;
@@ -192,6 +194,7 @@ export interface IpcRendererEvents {
192194
'k8s-integrations': (integrations: Record<string, boolean | string>) => void;
193195
'service-changed': (services: ServiceEntry[]) => void;
194196
'service-error': (service: ServiceEntry, errorMessage: string) => void;
197+
'steve-port': (port: number) => void;
195198
'kubernetes-errors-details': (
196199
titlePart: string,
197200
mainMessage: string,

0 commit comments

Comments
 (0)