11import { ChildProcess , spawn } from 'child_process' ;
22import os from 'os' ;
33import path from 'path' ;
4- import { setTimeout } from 'timers/promises' ;
54
65import Electron from 'electron' ;
76
87import K3sHelper from '@pkg/backend/k3sHelper' ;
8+ import { getIpcMainProxy } from '@pkg/main/ipcMain' ;
99import Latch from '@pkg/utils/latch' ;
1010import Logging from '@pkg/utils/logging' ;
1111import paths from '@pkg/utils/paths' ;
12+ import { send } from '@pkg/window' ;
1213
1314const 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 = / ^ w i n / 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}
0 commit comments