microsoft / microsoft/vscode

Splitting a reconnected terminal editor reuses its PTY; closing the copy terminates the original

Open
#336,191 0 comments 0 reactions 1 assignee Claimed by @meganrogge View on GitHub
Dominant language
TypeScript
Stars
193k
Forks
42.4k
PR merge metrics
PR metrics pending

Description

## Environment

- Official VS Code 1.137.0, commit `645f29cc3176500b4b5762ba887cf2a7f0ffdf2c`
- macOS, Apple Silicon, native `/bin/zsh -f`
- Separate user-data and extensions directories
- No Brandea Remote, tmux or other third-party terminal integration in the reproducer
- One small development extension executes the standard VS Code API commands and records `Terminal.processId`

## Reproduction

1. Create terminal editors A and B plus a text file in one editor group.
2. Run `workbench.action.reloadWindow` and wait for persistent terminals to reconnect.
3. Activate terminal A and run `workbench.action.splitEditorRight`.
4. Compare the `Terminal.processId` values of A and the newly opened terminal.
5. Dispose only the new terminal and check whether the original A process remains alive.

Expected: the split creates an independent process. Closing the split preserves A and B.

Observed on 2026-09-14: A reconnected with PID 14811, B with PID 14813. The split also reported PID 14811. Closing only the split terminated PID 14811; B remained alive. The app itself did not crash in this probe. The source group contained both terminals and a text file. This probe does not programmatically select multiple tabs.

A self-contained reproduction is included below. It uses a dedicated-folder marker and refuses normal installed-extension mode. A successful diagnostic does not mean the product behaved correctly.

## Suspected cause

[`TerminalEditor.setInput`](https://github.com/microsoft/vscode/blob/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts) passes the instance launch configuration directly into `setCopyLaunchConfig`. [`TerminalEditorInput.copy`](https://github.com/microsoft/vscode/blob/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/src/vs/workbench/contrib/terminal/browser/terminalEditorInput.ts) then creates the copied instance from that configuration. [`reviveInput`](https://github.com/microsoft/vscode/blob/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/src/vs/workbench/contrib/terminal/browser/terminalEditorService.ts) constructs a configuration containing `attachPersistentProcess`. Carrying that descriptor into a copy matches the observed shared PID.

A native fix should obtain a fresh launch configuration for copies, preserve the source process and retain ordinary editor-selection behavior. Default and extension-contributed profiles should both be checked. Merely clearing the attachment descriptor needs validation of profile resolution and working-directory behavior before being considered sufficient.

## Why an extension workaround is incomplete

The public Tab API does not expose the selected editor set. [`mainThreadEditorTabs`](https://github.com/microsoft/vscode/blob/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/src/vs/workbench/api/browser/mainThreadEditorTabs.ts) explicitly ignores `EDITORS_SELECTION`. The Terminal split commands handle an active terminal or the terminal panel selection, not mixed editor-tab selections. The public Terminal API cannot replace a running terminal's PTY in place.

The native copy path should therefore be tested with plain and reconnected terminals, multiple terminal tabs, mixed file/terminal selection, and closing the resulting copies. No installed Microsoft file was modified during diagnosis.

## Self-contained macOS reproduction

This creates a temporary project and an isolated development host. It does not change the existing VS Code profile. Run these steps in a terminal, then allow the probe about ten seconds to reload its own window and write `project/result.json`.

```sh
probe_root="$(mktemp -d "${TMPDIR%/}/vscode-copy.XXXXXX")"
probe_root="$(cd "$probe_root" && pwd -P)"
mkdir -p "$probe_root/project" "$probe_root/user-data/User" "$probe_root/extensions" "$probe_root/controller"
printf '%s\n' 'owned native copy probe' > "$probe_root/project/.owned-native-copy-probe"
printf '%s\n' 'Native terminal copy regression.' > "$probe_root/project/control.txt"
cat > "$probe_root/user-data/User/settings.json" <<'PROBE_SETTINGS'
{
"workbench.startupEditor": "none",
"workbench.editor.autoLockGroups": {},
"terminal.integrated.defaultProfile.osx": "zsh",
"terminal.integrated.profiles.osx": {
"zsh": {
"path": "/bin/zsh",
"args": [
"-f"
]
}
},
"terminal.integrated.confirmOnKill": "never",
"telemetry.telemetryLevel": "off"
}
PROBE_SETTINGS
cat > "$probe_root/controller/package.json" <<'PROBE_PACKAGE'
{
"name": "native-copy-regression",
"displayName": "Native terminal copy regression probe",
"publisher": "brandea-local-test",
"version": "0.0.1",
"private": true,
"engines": {
"vscode": "^1.93.0"
},
"activationEvents": [
"onStartupFinished"
],
"main": "./extension.cjs"
}
PROBE_PACKAGE
cat > "$probe_root/controller/extension.cjs" <<'PROBE_CODE'
const vscode = require('vscode');
const fs = require('node:fs/promises');
const path = require('node:path');
const folder = process.env.NATIVE_COPY_REPRO_FOLDER;
if (!folder || !path.isAbsolute(folder))
throw Error('A dedicated NATIVE_COPY_REPRO_FOLDER is required.');
const reportFile = path.join(folder, 'result.json');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const wait = async (predicate) => { for (let n = 0; n < 160; n++) {
const value = await predicate();
if (value)
return value;
await sleep(100);
} throw Error('Native Bestätigung fehlt'); };
const pidOf = t => Promise.race([t.processId, sleep(10000).then(() => { throw Error('PTY-Prozess fehlt'); })]);
const alive = pid => { try {
process.kill(pid, 0);
return true;
}
catch {
return false;
} };
exports.activate = async (context) => {
if (context.extensionMode !== vscode.ExtensionMode.Development)
throw Error('Development host required');
if ((await fs.readFile(path.join(folder, '.owned-native-copy-probe'), 'utf8')).trim() !== 'owned native copy probe')
throw Error('Dedicated probe marker missing');
if (vscode.workspace.workspaceFolders?.[0]?.uri.fsPath !== folder)
throw Error('Falscher Prüfordner');
let prior;
try {
prior = JSON.parse(await fs.readFile(reportFile, 'utf8'));
}
catch { }
if (prior && prior.phase !== 1)
return;
const owned = new Set();
let report = { version: vscode.version, remoteExtensionPresent: !!vscode.extensions.getExtension('brandea.remote') };
try {
if (!prior) {
if (vscode.window.terminals.length)
throw Error('Dedicated test host contains unexpected terminals');
const terminals = [];
for (const name of ['Native A', 'Native B']) {
const terminal = vscode.window.createTerminal({ name, cwd: folder, shellPath: '/bin/zsh', shellArgs: ['-f'], location: { viewColumn: vscode.ViewColumn.One, preserveFocus: false } });
owned.add(terminal);
terminal.show();
terminals.push({ name, pid: await pidOf(terminal) });
}
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(folder, 'control.txt')));
await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.One, preview: false });
vscode.window.terminals[0].show();
await sleep(500);
report = { ...report, phase: 1, terminals, groups: vscode.window.tabGroups.all.map(g => ({ viewColumn: g.viewColumn, tabs: g.tabs.map(t => t.label) })) };
await fs.writeFile(reportFile, JSON.stringify(report, null, 2));
await sleep(700);
await vscode.commands.executeCommand('workbench.action.reloadWindow');
return;
}
await wait(() => vscode.window.terminals.length === 2);
await sleep(1000);
const originals = await Promise.all(vscode.window.terminals.map(async (terminal) => ({ terminal, name: terminal.name, pid: await pidOf(terminal) })));
if (originals.some(t => !prior.terminals.some(saved => saved.name === t.name && saved.pid === t.pid)))
throw Error('Restored test identity changed');
for (const item of originals)
owned.add(item.terminal);
const original = originals.find(t => t.name === 'Native A') || originals[0];
const neighbour = originals.find(t => t !== original);
original.terminal.show();
await sleep(300);
const before = new Set(vscode.window.terminals);
await vscode.commands.executeCommand('workbench.action.splitEditorRight');
const duplicate = await wait(() => vscode.window.terminals.find(t => !before.has(t)));
owned.add(duplicate);
const splitPid = await pidOf(duplicate);
const samePid = splitPid === original.pid;
report = { ...report, phase: 2, prior, revived: originals.map(({ terminal, ...rest }) => rest), splitPid, sharedNativePty: samePid, sourceGroupTabs: vscode.window.tabGroups.all.map(g => ({ viewColumn: g.viewColumn, tabs: g.tabs.map(t => t.label) })), originalAliveBeforeClose: alive(original.pid) };
duplicate.dispose();
await sleep(1300);
report.originalAliveAfterCopyClosed = alive(original.pid);
report.neighbourAliveAfterCopyClosed = alive(neighbour.pid);
report.ok = true;
}
catch (error) {
report.ok = false;
report.error = String(error.stack);
}
finally {
if (report.phase !== 1) {
await fs.writeFile(reportFile, JSON.stringify(report, null, 2));
for (const t of owned)
t.dispose();
}
}
};
PROBE_CODE
NATIVE_COPY_REPRO_FOLDER="$probe_root/project" /usr/bin/env -u VSCODE_IPC_HOOK_CLI -u VSCODE_PID '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code' --new-window --disable-workspace-trust --disable-extensions --user-data-dir "$probe_root/user-data" --extensions-dir "$probe_root/extensions" --extensionDevelopmentPath="$probe_root/controller" "$probe_root/project"
```

The defect is reproduced when phase 2 contains `sharedNativePty: true`, `originalAliveAfterCopyClosed: false`, and `neighbourAliveAfterCopyClosed: true`. `ok: true` only indicates the probe completed. The probe cleans up its own terminal objects. Close its isolated window afterward.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.