Splitting a reconnected terminal editor reuses its PTY; closing the copy terminates the original
- 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
Assessment
This issue has not been assessed yet.