Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 36 additions & 24 deletions web/packages/teleterm/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import path from 'node:path';
import { app, dialog, globalShortcut, nativeTheme, shell } from 'electron';

import { CUSTOM_PROTOCOL } from 'shared/deepLinks';
import { ensureError } from 'shared/utils/error';

import { parseDeepLink } from 'teleterm/deepLinks';
import Logger from 'teleterm/logger';
Expand Down Expand Up @@ -83,19 +84,30 @@ async function initializeApp(): Promise<void> {
const windowsManager = new WindowsManager(appStateFileStorage, settings);

process.on('uncaughtException', (error, origin) => {
logger.error(origin, error);
app.quit();
logger.error('Uncaught exception', origin, error);
showDialogWithError(`Uncaught exception (${origin} origin)`, error);
app.exit(1);
});

// init main process
const mainProcess = MainProcess.create({
settings,
logger,
configService,
appStateFileStorage,
configFileStorage,
windowsManager,
});
let mainProcess: MainProcess;
try {
mainProcess = MainProcess.create({
settings,
logger,
configService,
appStateFileStorage,
configFileStorage,
windowsManager,
});
} catch (error) {
const message = 'Could not initialize the main process';
logger.error(message, error);
showDialogWithError(message, error);
// app.exit(1) isn't equivalent to throwing an error, use an explicit return to stop further
// execution. See https://github.com/gravitational/teleport/issues/56272.
app.exit(1);
return;
}

//TODO(gzdunek): Make sure this is not needed after migrating to Vite.
app.on(
Expand Down Expand Up @@ -163,14 +175,10 @@ async function initializeApp(): Promise<void> {
allowList: rootClusterProxyHostAllowList,
});
})().catch(error => {
const message =
'Could not initialize tsh daemon client in the main process';
const message = 'Could not initialize the tshd client in the main process';
logger.error(message, error);
dialog.showErrorBox(
'Error during main process startup',
`${message}: ${error}`
);
app.quit();
showDialogWithError(message, error);
app.exit(1);
});

app
Expand All @@ -189,13 +197,10 @@ async function initializeApp(): Promise<void> {
windowsManager.createWindow();
})
.catch(error => {
const message = 'Could not initialize the app';
const message = 'Could not create the main app window';
logger.error(message, error);
dialog.showErrorBox(
'Error during app initialization',
`${message}: ${error}`
);
app.quit();
showDialogWithError(message, error);
app.exit(1);
});

// Limit navigation capabilities to reduce the attack surface.
Expand Down Expand Up @@ -418,3 +423,10 @@ function launchDeepLink(
// Otherwise the app would receive focus but nothing would be visible in the UI.
windowsManager.launchDeepLink(result);
}

function showDialogWithError(title: string, unknownError: unknown) {
const error = ensureError(unknownError);
// V8 includes the error message in the stack, so there's no need to append stack to message.
const content = error.stack || error.message;
dialog.showErrorBox(title, content);
}
29 changes: 20 additions & 9 deletions web/packages/teleterm/src/mainProcess/mainProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ export default class MainProcess {
);
}

/**
* create starts necessary child processes such as tsh daemon and the shared process. It also sets
* up IPC handlers and resolves the network addresses under which the child processes set up gRPC
* servers.
*
* create might throw an error if spawning a child process fails, see initTshd for more details.
*/
static create(opts: Options) {
const instance = new MainProcess(opts);
instance.init();
Expand All @@ -162,15 +169,10 @@ export default class MainProcess {
private init() {
this.updateAboutPanelIfNeeded();
this.setAppMenu();
try {
this.initTshd();
this.initSharedProcess();
this.initResolvingChildProcessAddresses();
this.initIpc();
} catch (err) {
this.logger.error('Failed to start main process: ', err.message);
app.exit(1);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error handling was moved out of here to main.ts so that we can properly stop execution code after catching an error, as app.exit(1) does not stop execution immediately (see RCA for more info).

}
this.initTshd();
this.initSharedProcess();
this.initResolvingChildProcessAddresses();
this.initIpc();
}

async getTshdClient(): Promise<TshdClient> {
Expand All @@ -191,6 +193,15 @@ export default class MainProcess {
const { binaryPath, homeDir } = this.settings.tshd;
this.logger.info(`Starting tsh daemon from ${binaryPath}`);

// spawn might either fail immediately by throwing an error or cause the error event to be emitted
Comment thread
ravicious marked this conversation as resolved.
// on the process value returned by spawn.
//
// Some spawn failures result in an error being thrown immediately such as EPERM on Windows [1].
// This might be related to the fact that in Node.js, an error event causes an error to be
// thrown if there are no listeners for the error event itself [2].
//
// [1] https://stackoverflow.com/a/42262771
// [2] https://nodejs.org/docs/latest-v22.x/api/errors.html#error-propagation-and-interception
this.tshdProcess = spawn(
binaryPath,
['daemon', 'start', ...this.getTshdFlags()],
Expand Down
18 changes: 15 additions & 3 deletions web/packages/teleterm/src/mainProcess/resolveNetworkAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function waitForMatchInStdout(
let chunks = '';

const timeout = setTimeout(() => {
rejectOnError(
rejectWithCleanup(
new ResolveError(requestedAddress, process, 'the operation timed out')
);
}, timeoutMs);
Expand All @@ -94,11 +94,23 @@ function waitForMatchInStdout(
}
};

const rejectOnError = (error: Error) => {
const rejectWithCleanup = (error: Error) => {
reject(error);
removeListeners();
};

const rejectOnError = (error: Error) => {
const errorToReport = error.message.includes(process.spawnfile)
? error
: // Attach spawnfile so that the process can be identified without looking at the stacktrace.
// resolveNetworkAddress is the only function that ends up surfacing to the UI the error
// event of a process.
new Error(`${process.spawnfile}: ${error.message}`, {
cause: error,
});
Comment on lines +103 to +110
Copy link
Copy Markdown
Member Author

@ravicious ravicious Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is necessary, but I don't think there's any guarantee that the error message of an error from the error event of a process is going to include the spawnfile.

It certainly happens for EACCESS on macOS as shown in the screenshot, hence why I'm prepending the spawnfile only if the message doesn't already include it. Since it's an async error, the stacktrace itself is not going to include any relevant part like MainProcess.initTshd, that's why I'm trying to ensure that the error message itself identifies the process.

macos-spawn-error

rejectWithCleanup(errorToReport);
};

const rejectOnClose = (code: number, signal: NodeJS.Signals) => {
const codeOrSignal = [
// code can be 0, so we cannot just check it the same way as the signal.
Expand All @@ -108,7 +120,7 @@ function waitForMatchInStdout(
.filter(Boolean)
.join(' ');
const details = codeOrSignal ? ` with ${codeOrSignal}` : '';
rejectOnError(
rejectWithCleanup(
new ResolveError(
requestedAddress,
process,
Expand Down
Loading