Skip to content

Conversation

@JBlitzar
Copy link

In reference to #258821

Uses the open command rather than launching via Electron directly.
Test by replacing the contents of /usr/local/bin/code with the proposed changes (or rebuilding).

Copy link
Collaborator

@deepak1556 deepak1556 left a comment

Choose a reason for hiding this comment

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

The shell script calls into a cli script in headless mode, the cli inturn opens the actual application based on the arguments via open

// On Big Sur, we spawn using the open command to obtain behavior
// similar to if the app was launched from the dock
// https://github.com/microsoft/vscode/issues/102975
// The following args are for the open command itself, rather than for VS Code:
// -n creates a new instance.
// Without -n, the open command re-opens the existing instance as-is.
// -g starts the new instance in the background.
// Later, Electron brings the instance to the foreground.
// This way, Mac does not automatically try to foreground the new instance, which causes
// focusing issues when the new instance only sends data to a previous instance and then closes.
const spawnArgs = ['-n', '-g'];
// -a opens the given application.
spawnArgs.push('-a', process.execPath); // -a: opens a specific application
if (args.verbose || args.status) {
spawnArgs.push('--wait-apps'); // `open --wait-apps`: blocks until the launched app is closed (even if they were already running)
// The open command only allows for redirecting stderr and stdout to files,
// so we make it redirect those to temp files, and then use a logger to
// redirect the file output to the console
for (const outputType of args.verbose ? ['stdout', 'stderr'] : ['stdout']) {
// Tmp file to target output to
const tmpName = randomPath(tmpdir(), `code-${outputType}`);
writeFileSync(tmpName, '');
spawnArgs.push(`--${outputType}`, tmpName);
// Listener to redirect content to stdout/stderr
processCallbacks.push(async child => {
try {
const stream = outputType === 'stdout' ? process.stdout : process.stderr;
const cts = new CancellationTokenSource();
child.on('close', () => {
// We must dispose the token to stop watching,
// but the watcher might still be reading data.
setTimeout(() => cts.dispose(true), 200);
});
await watchFileContents(tmpName, chunk => stream.write(chunk), () => { /* ignore */ }, cts.token);
} finally {
unlinkSync(tmpName);
}
});
}
}
for (const e in env) {
// Ignore the _ env var, because the open command
// ignores it anyway.
// Pass the rest of the env vars in to fix
// https://github.com/microsoft/vscode/issues/134696.
if (e !== '_') {
spawnArgs.push('--env');
spawnArgs.push(`${e}=${env[e]}`);
}
}
spawnArgs.push('--args', ...argv.slice(2)); // pass on our arguments
if (env['VSCODE_DEV']) {
// If we're in development mode, replace the . arg with the
// vscode source arg. Because the OSS app isn't bundled,
// it needs the full vscode source arg to launch properly.
const curdir = '.';
const launchDirIndex = spawnArgs.indexOf(curdir);
if (launchDirIndex !== -1) {
spawnArgs[launchDirIndex] = resolve(curdir);
}
}
// We already passed over the env variables
// using the --env flags, so we can leave them out here.
// Also, we don't need to pass env._, which is different from argv._
child = spawn('open', spawnArgs, { ...options, env: {} });

I don't see how this makes it any different than what we have today. Also we cannot skip the cli script since it takes of various argument processing, some don't require actual application to be opened.

@JBlitzar
Copy link
Author

Ah I see. Then it's mostly a design choice. Why are we using -n? This appears to be intentional to create a new instance. Don't most people want it to just open in the same existing instance?

@deepak1556
Copy link
Collaborator

We have our own implementation of singleton instance

private async claimInstance(logService: ILogService, environmentMainService: IEnvironmentMainService, lifecycleMainService: ILifecycleMainService, instantiationService: IInstantiationService, productService: IProductService, retry: boolean): Promise<NodeIPCServer> {
// Try to setup a server for running. If that succeeds it means
// we are the first instance to startup. Otherwise it is likely
// that another instance is already running.
let mainProcessNodeIpcServer: NodeIPCServer;
try {
mark('code/willStartMainServer');
mainProcessNodeIpcServer = await nodeIPCServe(environmentMainService.mainIPCHandle);
mark('code/didStartMainServer');
Event.once(lifecycleMainService.onWillShutdown)(() => mainProcessNodeIpcServer.dispose());
} catch (error) {
// Handle unexpected errors (the only expected error is EADDRINUSE that
// indicates another instance of VS Code is running)
if (error.code !== 'EADDRINUSE') {
// Show a dialog for errors that can be resolved by the user
this.handleStartupDataDirError(environmentMainService, productService, error);
// Any other runtime error is just printed to the console
throw error;
}
// there's a running instance, let's connect to it
let client: NodeIPCClient<string>;
try {
client = await nodeIPCConnect(environmentMainService.mainIPCHandle, 'main');
} catch (error) {
// Handle unexpected connection errors by showing a dialog to the user
if (!retry || isWindows || error.code !== 'ECONNREFUSED') {
if (error.code === 'EPERM') {
this.showStartupWarningDialog(
localize('secondInstanceAdmin', "Another instance of {0} is already running as administrator.", productService.nameShort),
localize('secondInstanceAdminDetail', "Please close the other instance and try again."),
productService
);
}
throw error;
}
// it happens on Linux and OS X that the pipe is left behind
// let's delete it, since we can't connect to it and then
// retry the whole thing
try {
unlinkSync(environmentMainService.mainIPCHandle);
} catch (error) {
logService.warn('Could not delete obsolete instance handle', error);
throw error;
}
return this.claimInstance(logService, environmentMainService, lifecycleMainService, instantiationService, productService, false);
}
// Tests from CLI require to be the only instance currently
if (environmentMainService.extensionTestsLocationURI && !environmentMainService.debugExtensionHost.break) {
const msg = `Running extension tests from the command line is currently only supported if no other instance of ${productService.nameShort} is running.`;
logService.error(msg);
client.dispose();
throw new Error(msg);
}
// Show a warning dialog after some timeout if it takes long to talk to the other instance
// Skip this if we are running with --wait where it is expected that we wait for a while.
// Also skip when gathering diagnostics (--status) which can take a longer time.
let startupWarningDialogHandle: Timeout | undefined = undefined;
if (!environmentMainService.args.wait && !environmentMainService.args.status) {
startupWarningDialogHandle = setTimeout(() => {
this.showStartupWarningDialog(
localize('secondInstanceNoResponse', "Another instance of {0} is running but not responding", productService.nameShort),
localize('secondInstanceNoResponseDetail', "Please close all other instances and try again."),
productService
);
}, 10000);
}
const otherInstanceLaunchMainService = ProxyChannel.toService<ILaunchMainService>(client.getChannel('launch'), { disableMarshalling: true });
const otherInstanceDiagnosticsMainService = ProxyChannel.toService<IDiagnosticsMainService>(client.getChannel('diagnostics'), { disableMarshalling: true });
// Process Info
if (environmentMainService.args.status) {
return instantiationService.invokeFunction(async () => {
const diagnosticsService = new DiagnosticsService(NullTelemetryService, productService);
const mainDiagnostics = await otherInstanceDiagnosticsMainService.getMainDiagnostics();
const remoteDiagnostics = await otherInstanceDiagnosticsMainService.getRemoteDiagnostics({ includeProcesses: true, includeWorkspaceMetadata: true });
const diagnostics = await diagnosticsService.getDiagnostics(mainDiagnostics, remoteDiagnostics);
console.log(diagnostics);
throw new ExpectedError();
});
}
// Windows: allow to set foreground
if (isWindows) {
await this.windowsAllowSetForegroundWindow(otherInstanceLaunchMainService, logService);
}
// Send environment over...
logService.trace('Sending env to running instance...');
await otherInstanceLaunchMainService.start(environmentMainService.args, process.env as IProcessEnvironment);
// Cleanup
client.dispose();
// Now that we started, make sure the warning dialog is prevented
if (startupWarningDialogHandle) {
clearTimeout(startupWarningDialogHandle);
}
throw new ExpectedError('Sent env to running instance. Terminating...');
}
// Print --status usage info
if (environmentMainService.args.status) {
console.log(localize('statusWarning', "Warning: The --status argument can only be used if {0} is already running. Please run it again after {0} has started.", productService.nameShort));
throw new ExpectedError('Terminating...');
}
// Set the VSCODE_PID variable here when we are sure we are the first
// instance to startup. Otherwise we would wrongly overwrite the PID
process.env['VSCODE_PID'] = String(process.pid);
return mainProcessNodeIpcServer;
}
, -n is used to route to our logic.

@JBlitzar
Copy link
Author

Then I guess this is a separate question. What it ends up doing visually is that it creates a new instance that instantly quits and transitions focus to the old instance, but then there's an extra VSC in my dock.

@JBlitzar
Copy link
Author

Thanks for all the clarification

@deepak1556
Copy link
Collaborator

What it ends up doing visually is that it creates a new instance that instantly quits and transitions focus to the old instance, but then there's an extra VSC in my dock

Yup thats correct, the extra icon is something I can confirm as well but I tend to rule this out as macOS expectation. The icon takes the space of recently launched application, unless you have VSCode pinned to the dock then a different application launch should replace the extra icon. If there is an open option without changing our cli behavior, happy to try that.

@JBlitzar
Copy link
Author

JBlitzar commented Aug 1, 2025

Hm I see. I'll try to see if I can think of something.

@JBlitzar
Copy link
Author

JBlitzar commented Aug 1, 2025

Funnily enough, I don't seem to be able to reproduce it appearing as a recently quit app in the dock anymore. Either way, perhaps inserting app.dock.hide() would benefit

@deepak1556
Copy link
Collaborator

Remembered we had a discussion around revisiting the use of dock.hide when transitioning to use open command #131213 (comment) but we never got to it. Feel free to give it a try in this PR.

@JBlitzar JBlitzar closed this by deleting the head repository Aug 2, 2025
@JBlitzar
Copy link
Author

JBlitzar commented Aug 2, 2025

all right, I'll give it a try

@vs-code-engineering vs-code-engineering bot locked and limited conversation to collaborators Sep 16, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants