mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* 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
230 lines
8.3 KiB
JavaScript
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)
|
|
}
|