Files
orca/config/scripts/node-pty-master-cloexec-patch.test.mjs
T
Neil aa3ae6f56e fix(ssh): close the pty master fd leak on relay hosts too (#17920)
* fix(ssh): close the pty master fd leak on Linux relay hosts

The app gets the FD_CLOEXEC patch through pnpm patchedDependencies (#17914);
the relay installs stock node-pty from npm, where no pnpm patch reaches. Linux
is where that matters -- it is the only relay platform that takes forkpty()'s
no-atomic-O_CLOEXEC path, and it is also the only one that already compiles
node-pty at install time, so the fix costs a second compile rather than a first.

Ships the patch as a relay asset applied like the existing Windows console-list
one, and rebuilds only after the probe has proven node-pty loadable. The rebuild
is non-fatal by construction: the working build is moved aside first and moved
back on any failure, a failed attempt drops a skip marker so the compile is
attempted at most once per relay directory, and the caller swallows the whole
step. macOS and Windows relays never run it.

Measured on node:22 with a relay-style npm install: before, the master is
cloexec=false and shows up as `26 -> /dev/pts/ptmx` in both a later pty child
and a later child_process child; after, cloexec=true and neither child sees it.

Closes #17915.

* test(ssh): feed the cloexec patch exec to the hand-rolled namespace fixtures

These sequences are positional, so the new Linux-only patch exec swallowed the
READY slot and every install/repair case timed out waiting for the relay.

* fix(ssh): patch the pty master before publishing the shared native-deps tree

* fix(ssh): refuse to publish a native-deps tree whose cloexec patch did not take
2026-09-02 03:02:27 -07:00

230 lines
8.3 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-master close-on-exec 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('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 that does not leak', () => {
for (const platform of ['darwin', 'win32']) {
const fixture = writeRelayFixture()
const calls = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform,
rebuild: () => calls.push('rebuild'),
verify: () => 'isolated'
})
expect(status).toBe('skipped:not-linux')
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')
})
})
function writeRelayFixture({ version = '1.1.0', source = STOCK_SOURCE, build = true } = {}) {
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 buildPath = join(nodePtyDir, 'build', 'Release', 'pty.node')
mkdirSync(join(nodePtyDir, 'src', 'unix'), { recursive: true })
writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version }))
writeFileSync(sourcePath, source)
const fixture = {
root,
sourcePath,
buildPath,
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)
}