Skip to content
Open
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
61 changes: 61 additions & 0 deletions .github/workflows/ffi-experiment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: FFI PTY experiment

on:
push:
branches: [experiment/ffi-pty]
paths:
- "experiments/ffi-pty/**"
- "test/experiments/ffi-pty/**"
- ".github/workflows/ffi-experiment.yml"
pull_request:
paths:
- "experiments/ffi-pty/**"
- "test/experiments/ffi-pty/**"
- ".github/workflows/ffi-experiment.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
test:
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
name: macOS 15 ARM64
- runner: macos-15-intel
name: macOS 15 x64
- runner: ubuntu-22.04
name: Ubuntu 22.04 x64
- runner: ubuntu-22.04-arm
name: Ubuntu 22.04 ARM64
- runner: windows-2022
name: Windows Server 2022 x64
- runner: windows-2025
name: Windows Server 2025 x64
- runner: windows-11-arm
name: Windows 11 ARM64
name: ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 26.12.0
- name: Test Windows without a helper executable
if: runner.os == 'Windows'
run: node --experimental-ffi --test --test-timeout=60000 test/experiments/ffi-pty/windows.test.mts
- name: Build native Windows helper
if: runner.os == 'Windows'
run: node experiments/ffi-pty/build-windows-helper.mts
- run: node --experimental-ffi --test --test-timeout=60000 test/experiments/ffi-pty/*.test.mts
- name: Upload native Windows helper
if: runner.os == 'Windows'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ffi-helper-${{ matrix.runner }}-${{ runner.arch }}
path: build/ffi-pty/*/helper.exe
if-no-files-found: error
69 changes: 69 additions & 0 deletions experiments/ffi-pty/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Cross-platform Node FFI PTY experiment

This opt-in experiment creates a PTY without a node-pty native addon. It requires Node.js 26.12.0 with `--experimental-ffi`. It is not imported by node-pty's public entry point and does not change the public `IPty` API.

## Try it

```sh
node --experimental-ffi experiments/ffi-pty/probe.mts
node --experimental-ffi --test test/experiments/ffi-pty/*.test.mts
```

The facade exports `spawnPty(file, args, options)`, which resolves to a PTY with `pid`, `cols`, `rows`, `onData`, `onExit`, `write`, `resize`, `kill`, `pause`, `resume`, `close`, and `exited`.

Supported spawn options are `cols` (80), `rows` (24), `cwd` (current directory), `env` (current environment), and UTF-8 `encoding`. A supplied `env` replaces the inherited environment; `TERM=xterm-256color` is added if absent. Dimensions must be 1–32767. Unknown options and non-UTF-8 encodings are rejected before a PTY is allocated.

This is a small experimental API, not full `IPty` parity. It does not support credentials such as `uid`/`gid`, pixel dimensions, ConPTY DLL or cursor-inheritance options, or encodings other than UTF-8.

## Platform paths

| Platform | Native path and current limit |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| macOS | `openpty` and `posix_spawn` from `libSystem`; a Node bootstrap calls public `login_tty` then `execve` to set the controlling terminal; resize delegates to `/bin/stty`. |
| Linux | glibc `openpty` and `posix_spawn`; requires glibc 2.34 or newer; resize delegates to `stty`. |
| Windows | System ConPTY APIs; requires Windows 10 version 1809 or Windows Server 2019 or newer. |

On Unix, `uv_poll_init` sets the PTY master nonblocking through a temporary libuv loop. The shared backend serializes writes, retries `EAGAIN` and `EINTR`, caps queued input at 8 MiB, and yields between 64 KiB writes. After child exit, it waits for output EOF for up to 1.5 seconds of unpaused time so inherited descriptors cannot hold exit open indefinitely. Explicitly pausing output suspends that budget; an unread stream still has the bounded timeout. Unix resize runs the system `stty` command; Windows uses ConPTY resize. ([libuv poll API](https://docs.libuv.org/en/v1.x/poll.html))

The implementation relies on Node's experimental and unsafe FFI interface ([Node.js FFI](https://nodejs.org/api/ffi.html)), Unix PTY and process APIs ([Apple `posix_spawn`](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/posix_spawn.2.html), [Apple `login_tty`](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/login_tty.3.html), [Linux `openpty`](https://www.man7.org/linux/man-pages/man3/openpty.3.html), [Linux `posix_spawn`](https://man7.org/linux/man-pages/man3/posix_spawn.3.html)), and Windows' [CreatePseudoConsole API](https://learn.microsoft.com/en-us/windows/console/createpseudoconsole). Windows pseudoconsoles are a system facility; this experiment does not bundle a ConPTY DLL. The macOS bootstrap adds a Node startup before the target process. No performance or binary-size advantage has been measured.

Windows selects its implementation using the actual OS version from `RtlGetVersion`:

| System | Implementation |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| Windows 10 1809+, Windows 11 before 24H2, Server 2019/2022 (builds 17763–26099) | Node subprocess by default; verified native helper when available. |
| Windows 11 24H2+ and Server 2025 (build 26100+) | FFI in an in-process worker; no helper executable needed. |
| Before build 17763 | Unsupported: ConPTY is unavailable. |

The helper process contains a [known handle leak in older system ConPTY implementations](https://github.com/microsoft/terminal/issues/17903#issuecomment-2343499050), because exiting it releases its handles. Build 26100 fixes that leak and makes [`ClosePseudoConsole` nonblocking](https://learn.microsoft.com/en-us/windows/console/closepseudoconsole). All Windows paths use a kill-on-close job to clean up the target and its descendants. Modern Windows isolates synchronous ConPTY calls in a worker so blocked resize calls do not stall the main thread.

On affected Windows, the default helper runs with the installed Node executable. An optional C++ helper uses Win32 directly, without Node or its addon ABI. Automatic selection requires a valid Windows Authenticode signature matching the publisher specified by trusted release metadata. Missing files, absent publisher metadata, signature failures, and verification timeouts select Node instead. Development builds require no signing certificate.

Verification is cached for the host process lifetime. A verified helper remains locked against modification or replacement while that process runs; restart the host after installing or updating it. Verification uses Windows PowerShell. Windows may retrieve certificate trust information during verification; a bounded timeout falls back to Node.

A fast PE-header check rejects unsigned files before starting signature verification. It reads only the header fields locating the certificate table and its record header, never the executable payload. A certificate table only indicates that a signature may be present; Windows still validates the signed file and publisher before native execution. An empty publisher policy skips file inspection entirely.

To build the optional native helper or run its lifecycle tests, use Visual Studio C++ tools:

```sh
node experiments/ffi-pty/build-windows-helper.mts
```

The build writes `build/ffi-pty/win32-<arch>/helper.exe`; `--arch=x64` and `--arch=arm64` select a target explicitly. Spawning a terminal never compiles or downloads the helper. Normal terminal use does not require this file. Build it before running the full Windows test suite, which exercises the unsigned helper directly as an internal test fixture.

The signed-helper trust integration test runs only on disposable GitHub-hosted Windows runners; it adds a unique temporary certificate to the machine root store and removes it during cleanup.

CI artifacts are unsigned experimental builds. Public helper releases need a maintainer-owned Windows signing process; checksums and build provenance do not replace Authenticode or override application-control policy. See [Microsoft's signing guidance](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/code-signing-options).

<details>
<summary>Signing the optional helper for release</summary>

Use the maintainer's existing signing service or [Azure Artifact Signing](https://learn.microsoft.com/en-us/azure/artifact-signing/overview). Azure manages the publisher identity and keys; SignTool or the Azure GitHub Action applies the signature. The helper is a standalone EXE, so MSIX packaging is unnecessary.

Build and test each architecture, sign with SHA-256 and a timestamp, verify with `signtool verify /pa /all /v /tw helper.exe`, then include those exact bytes in the npm package. Reject missing timestamps and failed verification. Azure's short-lived certificates make timestamping essential. Keep signing credentials in the release infrastructure; GitHub OIDC avoids a long-lived client secret. See [signing integration](https://learn.microsoft.com/en-us/azure/artifact-signing/how-to-signing-integrations) and [OIDC setup](https://github.com/Azure/artifact-signing-action/blob/main/docs/OIDC.md).

The expected publisher is release metadata, not a user environment override. Do not pin a rotating leaf certificate thumbprint. Signing establishes publisher identity but does not bypass machine policy or guarantee immediate SmartScreen reputation.

</details>

The CI workflow tests macOS and Ubuntu on x64/ARM64, Windows Server 2022 and 2025 on x64, and Windows 11 on ARM64. Windows jobs also build the native helper and publish it as a downloadable artifact. Check workflow results for validation status.
115 changes: 115 additions & 0 deletions experiments/ffi-pty/build-windows-helper.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { gzipSync } from "node:zlib";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url)));
const SOURCE = join(ROOT, "experiments", "ffi-pty", "native");

export function targetArchitecture(
argv = process.argv.slice(2),
env = process.env,
): "x64" | "arm64" {
const requested =
argv.find((arg) => arg.startsWith("--arch="))?.slice("--arch=".length) ??
env.npm_config_arch ??
env.ARCH ??
process.arch;
if (requested !== "x64" && requested !== "arm64") {
throw new Error(`Windows helper supports x64 and arm64 targets; got ${requested}`);
}
return requested;
}

export function findVsDevCmd(env: NodeJS.ProcessEnv): string {
const root = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const vswhere = join(root, "Microsoft Visual Studio", "Installer", "vswhere.exe");
try {
const result = execFileSync(
vswhere,
[
"-latest",
"-products",
"*",
"-requires",
"Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"-find",
"Common7\\Tools\\VsDevCmd.bat",
],
{ encoding: "utf8", windowsHide: true },
)
.trim()
.split(/\r?\n/);
const path = result.find((entry) => entry.toLowerCase().endsWith("vsdevcmd.bat"));
if (path) return path;
} catch (error) {
throw new Error(`Unable to locate Visual Studio C++ tools with vswhere: ${String(error)}`);
}
throw new Error("Visual Studio C++ tools are missing (VsDevCmd.bat was not found)");
}

export function quoteResponsePath(value: string): string {
if (value.includes('"')) throw new Error("build paths may not contain quotes");
return `"${value}"`;
}

export function buildHelper(arch = targetArchitecture(), env = process.env): string {
if (process.platform !== "win32")
throw new Error("The Win32 helper can only be built on Windows");
const hostArch = process.arch === "arm64" ? "arm64" : "x64";
const outputDir = join(ROOT, "build", "ffi-pty", `win32-${arch}`);
mkdirSync(outputDir, { recursive: true });
const executable = join(outputDir, "helper.exe");
const response = join(tmpdir(), `pty-helper-${process.pid}-${arch}.rsp`);
const arguments_ = [
"/nologo",
"/std:c++17",
"/EHsc",
"/W4",
"/WX",
"/O2",
"/MT",
"/DUNICODE",
"/D_UNICODE",
"/D_WIN32_WINNT=0x0A00",
"/DWINVER=0x0A00",
`/Fe${quoteResponsePath(executable)}`,
`/Fo${quoteResponsePath(`${outputDir}/`)}`,
quoteResponsePath(join(SOURCE, "windows_helper.cpp")),
quoteResponsePath(join(SOURCE, "windows_helper_protocol.cpp")),
];
writeFileSync(response, `${arguments_.join("\r\n")}\r\n`, "utf8");
const vsDevCmd = findVsDevCmd(env);
const command = `call "${vsDevCmd}" -no_logo -arch=${arch} -host_arch=${hostArch} && cl.exe @"${response}"`;
const result = spawnSync("cmd.exe", ["/d", "/s", "/c", command], {
cwd: ROOT,
env,
encoding: "utf8",
windowsHide: true,
windowsVerbatimArguments: true,
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
if (result.error) throw result.error;
if (result.status !== 0)
throw new Error(`MSVC helper build failed with exit code ${result.status}`);

const bytes = statSync(executable).size;
const compressedBytes = gzipSync(readFileSync(executable), { level: 9 }).length;
const summary = `Windows helper (${arch}): ${bytes} bytes (${(bytes / 1024).toFixed(1)} KiB), gzip ${compressedBytes} bytes (${(compressedBytes / 1024).toFixed(1)} KiB) at ${executable}`;
console.log(summary);
if (env.GITHUB_STEP_SUMMARY)
writeFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`, { flag: "a" });
return executable;
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
buildHelper();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
55 changes: 55 additions & 0 deletions experiments/ffi-pty/contract.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Readable } from "node:stream";

export interface ExitStatus {
exitCode: number | null;
signal: number;
}

export interface SpawnOptions {
file: string;
args: string[];
cols: number;
rows: number;
cwd: string;
env: Record<string, string>;
}

export interface SpawnOptionsInput {
cols?: number;
rows?: number;
cwd?: string;
env?: Record<string, string>;
encoding?: "utf8";
uid?: never;
gid?: never;
pixelWidth?: never;
pixelHeight?: never;
useConptyDll?: never;
conptyInheritCursor?: never;
[key: string]: unknown;
}

export interface Backend {
pid: number;
readable: Readable;
write(data: Buffer): Promise<void>;
resize(cols: number, rows: number): Promise<void>;
kill(signal?: string): Promise<void>;
close(): Promise<void>;
exited: Promise<ExitStatus>;
}

export interface FfiPty {
readonly pid: number;
readonly cols: number;
readonly rows: number;
readonly exited: Promise<ExitStatus>;
onData(listener: (data: string) => void): { dispose(): void };
onExit(listener: (status: ExitStatus) => void): { dispose(): void };
write(data: string | Buffer): Promise<void>;
resize(cols: number, rows: number): Promise<void>;
kill(signal?: string): Promise<void>;
pause(): void;
resume(): void;
close(): Promise<void>;
}
61 changes: 61 additions & 0 deletions experiments/ffi-pty/darwin-bootstrap.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { dlopen, getInt32 } from "node:ffi";
import { fileURLToPath } from "node:url";

export function loginControllingTerminal(): void {
const { functions } = dlopen("/usr/lib/libSystem.B.dylib", {
login_tty: { arguments: ["int32"], return: "int32" },
__error: { arguments: [], return: "pointer" },
});
if (functions.login_tty(0) !== 0) {
throw new Error(`login_tty failed with errno ${getInt32(functions.__error())}`);
}
}

export function resetIgnoredSignals(): void {
const { functions } = dlopen("/usr/lib/libSystem.B.dylib", {
signal: { arguments: ["int32", "pointer"], return: "pointer" },
});
// Node ignores SIGPIPE and SIGXFSZ. Exec preserves ignored dispositions.
functions.signal(13, 0n);
functions.signal(25, 0n);
}

export function recoverTargetEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const privateKey = "PTY_FFI_TARGET_NODE_ENV";
const payload = env[privateKey];
const base = { ...env };
delete base[privateKey];
return { ...base, ...(payload ? JSON.parse(payload) : {}) };
}

export function splitTargetEnvironment(env: Record<string, string>): Record<string, string> {
const bootstrapEnv = Object.create(null) as Record<string, string>;
const nodeEnv = Object.create(null) as Record<string, string>;
for (const [key, value] of Object.entries(env)) {
if (key.startsWith("NODE_") || key === "PTY_FFI_TARGET_NODE_ENV") nodeEnv[key] = value;
else bootstrapEnv[key] = value;
}
bootstrapEnv.PTY_FFI_TARGET_NODE_ENV = JSON.stringify(nodeEnv);
return bootstrapEnv;
}

export function execTarget(argv: string[], env: NodeJS.ProcessEnv): never {
const [executable, argv0, ...args] = argv;
if (!executable || !argv0) throw new Error("Invalid Darwin PTY bootstrap arguments");
resetIgnoredSignals();
const targetEnv = recoverTargetEnvironment(env);
process.execve(executable, [argv0, ...args], targetEnv);
throw new Error("process.execve unexpectedly returned");
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
try {
loginControllingTerminal();
execTarget(process.argv.slice(2), process.env);
} catch (error) {
process.stderr.write(
`PTY child bootstrap failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 127;
}
}
Loading