Files
orca/config/scripts/node-pty-master-cloexec-patch.test.mjs
T
Neil 04ae62202a fix(ssh): close the macOS relay's per-terminal pty fd leak (#18534)
The relay asset from #17920 only rewrote the forkpty `default:` call site, which
sits in the `#else` arm of PtyFork's `#if defined(__APPLE__)`. macOS takes
`pty_posix_spawn`, so the asset had never patched anything a Mac executes -- and
`applyNodePtyMasterCloexecPatch` returned 'fixed' for any non-Linux host without
running the script at all, which is what publishes a tree to the shared
native-deps cache.

Stock `pty_posix_spawn` opens up to three throwaway ptys to push the real master
off fds 0-2 and never closes them: the cleanup loop is `for (; count > 0;
count--)`, but the first `posix_openpt()` in a running process already returns
>= 2, so it breaks with `count == 0` and the body never runs -- and where it does
run it closes `low_fds[count]`, never `low_fds[0]`. One orphaned /dev/ptmx fd per
terminal, for the life of the relay.

Ports the `low_fds` fix and the Apple-branch `pty_cloexec(master)` call from the
app's `config/patches/node-pty@1.1.0.patch`, byte-identical, and runs the gate on
darwin. macOS needs a different build layout than Linux: it has no `build/` at
all, so the fallback moved aside is `prebuilds/darwin-<arch>` -- which is also
what makes node-pty's install script fall through from "prebuild found" to
node-gyp -- and the compile writes a `build/Release` the loader checks first.
Verification is per-platform too: Linux's leak is inheritance (/proc), macOS's is
self-held (lsof).

Also corrects the asset's claim that "macOS re-opens the tty through uv_tty_init's
cloexec dup". Measured false: FD_CLOEXEC is not set on the master. What protects
it is POSIX_SPAWN_CLOEXEC_DEFAULT, one option away from gone since uid/gid drops
libuv back to fork()/exec() -- so the master is now marked there too.

Measured on darwin-arm64, one PTY per open/close cycle in a relay-shaped dir
running the relay's own commands:

  before  cycle:ptmx  1:1 2:2 3:3 ... 10:10   (10 after a settle)
  after   cycle:ptmx  1:0 2:0 3:0 ... 10:0    (0 after a settle)

Linux re-verified in docker node:22: inherited before, isolated after,
`already-patched` on the second run.

Refs #17915
Refs #8362
2026-09-03 16:13:07 -07:00

334 lines
13 KiB
JavaScript

import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
SKIP_MARKER_FILENAME,
applyNodePtyMasterCloexecPatch,
assertPatchedNodePtyMasterCloexecSource,
patchNodePtyMasterCloexecSource,
revertNodePtyMasterCloexecSource
} = require('../relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs')
// Byte-exact src/unix/pty.cc from the npm tarball the relay installs. The patch is keyed by its
// sha256, so a fixture that drifted from what npm ships would make every assertion below vacuous.
const STOCK_SOURCE = readFileSync(
resolve(import.meta.dirname, '__fixtures__', 'node-pty-1.1.0-unix-pty.cc'),
'utf8'
)
const projectDir = resolve(import.meta.dirname, '..', '..')
const cleanupDirs = []
afterEach(() => {
for (const dir of cleanupDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
describe('SSH relay node-pty pty fd-leak patch', () => {
it('adds the forkpty close-on-exec call and reverts to the published bytes', () => {
const fixture = writeRelayFixture()
expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(true)
const patched = readFileSync(fixture.sourcePath, 'utf8')
expect(patched).toContain('pty_cloexec(int fd)')
expect(patched).toContain('if (pty_cloexec(master) == -1)')
expect(() => assertPatchedNodePtyMasterCloexecSource(fixture.root)).not.toThrow()
expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(false)
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(patched)
expect(revertNodePtyMasterCloexecSource(fixture.root)).toBe(true)
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
})
it('rewrites the Apple branch, which is the only one macOS executes', () => {
const fixture = writeRelayFixture()
patchNodePtyMasterCloexecSource(fixture.root)
const patched = readFileSync(fixture.sourcePath, 'utf8')
// Stock's cleanup never runs: the first posix_openpt() already returns >= 2, so the loop
// breaks with count == 0 -- and where it does run it closes low_fds[count], never low_fds[0].
expect(STOCK_SOURCE).toContain('for (; count > 0; count--) {')
expect(patched).not.toContain('for (; count > 0; count--) {')
expect(patched).toContain('int low_fds[3] = {-1, -1, -1};')
expect(patched).toContain('for (size_t i = 0; i <= count && i < 3; i++) {')
// `default:` sits in the `#else` arm of PtyFork's `#if defined(__APPLE__)`, so marking only
// the forkpty call site left the master macOS actually opens unmarked.
expect(patched).toContain(
' if (pty_cloexec(master) == -1) {\n' +
' throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");\n' +
' }\n#else\n'
)
})
it('refuses a different node-pty version or an unrecognized source', () => {
const wrongVersion = writeRelayFixture({ version: '1.2.0-beta.4' })
expect(() => patchNodePtyMasterCloexecSource(wrongVersion.root)).toThrow('expected 1.1.0')
const drifted = writeRelayFixture({
source: `${STOCK_SOURCE}\n// drift\n`
})
expect(() => patchNodePtyMasterCloexecSource(drifted.root)).toThrow('unexpected node-pty')
const tampered = writeRelayFixture()
patchNodePtyMasterCloexecSource(tampered.root)
writeFileSync(tampered.sourcePath, `${readFileSync(tampered.sourcePath, 'utf8')}\n// drift\n`)
expect(() => assertPatchedNodePtyMasterCloexecSource(tampered.root)).toThrow('not installed')
})
it('keeps the rebuilt addon once a later child no longer inherits the master', () => {
const fixture = writeRelayFixture()
const calls = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => {
calls.push('rebuild')
writeBuild(fixture, 'patched-build')
},
verify: () => 'isolated'
})
expect(status).toBe('patched')
expect(calls).toEqual(['rebuild'])
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build')
expect(readFileSync(fixture.sourcePath, 'utf8')).not.toBe(STOCK_SOURCE)
expect(existsSync(fixture.backupDir)).toBe(false)
expect(existsSync(fixture.skipMarkerPath)).toBe(false)
})
it('keeps a rebuilt addon whose flag /proc could not confirm', () => {
const fixture = writeRelayFixture()
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => writeBuild(fixture, 'patched-build'),
verify: () => 'unverified'
})
expect(status).toBe('patched-unverified')
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build')
})
it('restores the working build when the compile fails, and never retries it', () => {
const fixture = writeRelayFixture()
const calls = []
const failed = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => {
calls.push('rebuild')
throw new Error('npm rebuild node-pty exited 1: no C++ toolchain')
},
verify: () => 'isolated'
})
expect(failed).toContain('failed:')
expect(failed).toContain('no C++ toolchain')
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build')
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
expect(existsSync(fixture.backupDir)).toBe(false)
expect(existsSync(fixture.skipMarkerPath)).toBe(true)
// Bounded, not backed off: a relay directory gets one compile attempt, ever.
const again = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => calls.push('rebuild'),
verify: () => 'isolated'
})
expect(again).toBe('skipped:earlier-attempt-failed')
expect(calls).toEqual(['rebuild'])
})
it('restores the working build when the rebuilt addon still leaks the master', () => {
const fixture = writeRelayFixture()
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => writeBuild(fixture, 'still-leaky-build'),
verify: () => {
throw new Error('rebuilt node-pty still leaks the pty master into later children')
}
})
expect(status).toContain('still leaks')
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build')
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
})
it('never compiles on a platform with no pty fds to leak', () => {
const fixture = writeRelayFixture()
const calls = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'win32',
rebuild: () => calls.push('rebuild'),
verify: () => 'isolated'
})
expect(status).toBe('skipped:unsupported-platform')
expect(calls).toEqual([])
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
})
it('compiles a macOS install out from under its shipped prebuild', () => {
// macOS has no build/ at all: node-pty runs `prebuilds/darwin-<arch>`, built from the leaky
// source. Moving `prebuilds` aside is what both arms the rollback and makes node-pty's own
// install script fall through from "prebuild found" to node-gyp.
const fixture = writeRelayFixture({ platform: 'darwin' })
const prebuildsPresentDuringRebuild = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'darwin',
arch: fixture.arch,
rebuild: () => {
prebuildsPresentDuringRebuild.push(existsSync(fixture.prebuildsDir))
writeCompiledBuild(fixture, 'patched-build')
},
verify: () => 'isolated'
})
expect(status).toBe('patched')
expect(prebuildsPresentDuringRebuild).toEqual([false])
expect(readFileSync(fixture.compiledPath, 'utf8')).toBe('patched-build')
// The published tree must hold no unpatched binary: node-pty's loader checks build/Release
// first, but falls back to a prebuild if that ever fails to load.
expect(existsSync(fixture.prebuildsDir)).toBe(false)
expect(existsSync(fixture.backupDir)).toBe(false)
})
it('restores the macOS prebuild when the first compile fails', () => {
// A macOS host has no toolchain guarantee at all, so this is the common failure, not the rare
// one -- and the relay has to come back on the prebuild exactly as it was installed.
const fixture = writeRelayFixture({ platform: 'darwin' })
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'darwin',
arch: fixture.arch,
rebuild: () => {
writeCompiledBuild(fixture, 'half-built')
throw new Error('npm rebuild node-pty exited 1: no C++ toolchain')
},
verify: () => 'isolated'
})
expect(status).toContain('failed:')
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build')
expect(existsSync(fixture.compiledPath)).toBe(false)
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
expect(existsSync(fixture.skipMarkerPath)).toBe(true)
})
it('will not rebuild a macOS install that has no prebuild to fall back on', () => {
const fixture = writeRelayFixture({ platform: 'darwin', build: false })
const calls = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'darwin',
arch: fixture.arch,
rebuild: () => calls.push('rebuild'),
verify: () => 'isolated'
})
expect(status).toBe('skipped:no-prebuild')
expect(calls).toEqual([])
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
})
it('leaves an already patched install alone', () => {
const fixture = writeRelayFixture()
patchNodePtyMasterCloexecSource(fixture.root)
const calls = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => calls.push('rebuild'),
verify: () => 'isolated'
})
expect(status).toBe('already-patched')
expect(calls).toEqual([])
})
it('will not rebuild an install that has no compiled addon to fall back on', () => {
const fixture = writeRelayFixture({ build: false })
const calls = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => calls.push('rebuild'),
verify: () => 'isolated'
})
expect(status).toBe('skipped:no-compiled-build')
expect(calls).toEqual([])
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
})
it('discards a backup stranded by an interrupted rebuild', () => {
const fixture = writeRelayFixture()
mkdirSync(fixture.backupDir, { recursive: true })
writeFileSync(join(fixture.backupDir, 'pty.node'), 'stranded-build')
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => writeBuild(fixture, 'patched-build'),
verify: () => 'isolated'
})
expect(status).toBe('patched')
expect(existsSync(fixture.backupDir)).toBe(false)
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build')
})
})
/**
* `buildPath` is the working build the patch has to be able to fall back on, which differs by
* platform: Linux compiles into build/Release at install time, macOS runs a shipped prebuild and
* has no build/ at all. `compiledPath` is where the rebuild writes on either.
*/
function writeRelayFixture({
version = '1.1.0',
source = STOCK_SOURCE,
build = true,
platform = 'linux',
arch = 'arm64'
} = {}) {
const root = mkdtempSync(join(projectDir, '.node-pty-cloexec-patch-test-'))
cleanupDirs.push(root)
const nodePtyDir = join(root, 'node_modules', 'node-pty')
const sourcePath = join(nodePtyDir, 'src', 'unix', 'pty.cc')
const compiledPath = join(nodePtyDir, 'build', 'Release', 'pty.node')
const prebuildsDir = join(nodePtyDir, 'prebuilds')
mkdirSync(join(nodePtyDir, 'src', 'unix'), { recursive: true })
writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version }))
writeFileSync(sourcePath, source)
const fixture = {
root,
arch,
sourcePath,
compiledPath,
prebuildsDir,
buildPath:
platform === 'darwin' ? join(prebuildsDir, `darwin-${arch}`, 'pty.node') : compiledPath,
backupDir: join(nodePtyDir, '.orca-cloexec-prepatch-release'),
skipMarkerPath: join(root, SKIP_MARKER_FILENAME)
}
if (build) {
writeBuild(fixture, 'stock-build')
}
return fixture
}
function writeBuild(fixture, contents) {
mkdirSync(resolve(fixture.buildPath, '..'), { recursive: true })
writeFileSync(fixture.buildPath, contents)
}
function writeCompiledBuild(fixture, contents) {
mkdirSync(resolve(fixture.compiledPath, '..'), { recursive: true })
writeFileSync(fixture.compiledPath, contents)
}