Files
orca/src/shared/relay-artifacts.ts
T
Neil 766b5b153c fix(relay): release the ConPTY conin handle after teardown, not before it (#18601)
A Windows SSH relay leaked one Windows File handle per terminal, for the life of
the relay process, across reconnects. node-pty's `kill()` flips `readable` on the
conin and conout sockets and destroys neither; `_cleanUpProcess` destroys
`_outSocket`, so only conin is stranded, and it wraps a real named-pipe handle
from `fs.openSync(term.conin, 'w')`.

The obvious fix -- and the one config/patches/node-pty@1.1.0.patch ships for the
desktop -- releases it at the top of the branch, before `_getConsoleProcessList()`
forks and before the native kill. Measured against a real Windows SSH host, that
is three times worse than leaving the leak alone: teardown aborts partway, the
forked console-list agent is never reaped, and both pipe handles stay alive.
Releasing it at the end of the branch instead is flat.

20 spawn/kill cycles, handles bucketed by NT object type, identical numbers
standalone and through a real relay:

  published node-pty        File +1/terminal,  Process flat
  desktop patch placement   File +2/terminal,  Process +1/terminal
  released last (this)      File flat,         Process flat

`windowsTerminal.js` takes the desktop's error-listener hunks verbatim. The conin
listener is not what fixes the leak -- adding it alone changed nothing -- but it
is what keeps a pipe error retiring one terminal instead of the host.

The desktop patch has the early placement and therefore the regression, measured
against its exact installed tree. Correcting it there needs its own verification
on a Windows desktop build, so the trees diverge on this one hunk deliberately and
a test pins that so a future patch sync cannot copy the bug back.
2026-09-04 01:56:42 -07:00

102 lines
4.5 KiB
TypeScript

/**
* What a packaged relay directory must contain, declared once. The build, the
* content hash, and the remote install probe all read this list; they used to
* keep three of their own, which is how the WSL helper reached the desktop app
* but never a relay.
*
* Order is load-bearing: the hash concatenates these files in sequence.
*
* Keep this file erasable-only TypeScript — build-relay.mjs imports it directly
* under Node's type stripping, which rejects enums, namespaces, and parameter
* properties.
*/
/** Every platform the relay is bundled for; each gets the full artifact set. */
export const RELAY_BUILD_PLATFORMS = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64'
] as const
export type RelayBuildPlatform = (typeof RELAY_BUILD_PLATFORMS)[number]
export function isWindowsRelayPlatform(platform: string): boolean {
return platform.startsWith('win32-')
}
export type RelayArtifact = {
filename: string
/** Only Windows relays ship it; other hosts must neither receive nor probe it. */
windowsOnly?: boolean
/**
* Present only when the build could produce it, so it is hashed when there and
* never probed. Required artifacts stay required: a probe that demanded an
* optional one would loop forever redeploying a relay that is already correct.
*/
optional?: boolean
/**
* Forked by the relay daemon as a long-lived child of its own. These are relay
* infrastructure, never user work, and the reap gate subtracts them from a daemon's
* child census; see src/main/ssh/relay-daemon-service-children.ts.
*/
daemonServiceChild?: boolean
}
/** The bare Windows process-table addon; see docs/reference/windows-process-enumeration.md. */
export const RELAY_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node'
export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [
{ filename: 'relay.js' },
{ filename: 'relay-watcher.js', daemonServiceChild: true },
{ filename: 'relay-ai-vault-service.js', daemonServiceChild: true },
{ filename: 'managed-hook-runtime.js' },
// Forked by the AI Vault title reader; without it a relay answers every WSL
// title request with no title and no error.
{ filename: 'wsl-transcript-fs-process-entry.js' },
{ filename: 'node-pty-1.1.0-console-list-agent-patch.cjs', windowsOnly: true },
// The ConPTY teardown release the desktop's own node-pty patch already carries; pnpm patches do
// not cross the SSH boundary, so a relay ran the unpatched npm tree and leaked one Windows File
// handle per terminal for the life of the relay process.
{ filename: 'node-pty-1.1.0-windows-pty-teardown-patch.cjs', windowsOnly: true },
// Only Linux relays run it, but it ships everywhere: the manifest's only
// platform axis is Windows, and a second one would buy nothing but a fork in
// the hash. Its presence is what moves a host to a fresh relay directory, and
// therefore to a re-install that can apply it.
{ filename: 'node-pty-1.1.0-master-cloexec-patch.cjs' },
// Optional because only a Windows build machine can compile it. Without it the
// relay reads the process table through a PowerShell scan instead -- slower,
// but correct, so a relay built anywhere else is still shippable.
{ filename: RELAY_WINDOWS_PROCESS_TREE_FILENAME, windowsOnly: true, optional: true }
]
/**
* The daemon's own service children, by entry filename. Anything else under a relay pid is
* either user work or unidentified, and both keep the relay unreapable.
*/
export const RELAY_DAEMON_SERVICE_ENTRY_FILENAMES: readonly string[] = RELAY_ARTIFACTS.filter(
(artifact) => artifact.daemonServiceChild
).map((artifact) => artifact.filename)
/** Written after the artifacts, so it is never an input to its own hash. */
export const RELAY_VERSION_FILENAME = '.version'
/** Written last by the installer; its absence means a torn install. */
export const RELAY_INSTALL_COMPLETE_FILENAME = '.install-complete'
/** Artifacts every relay must have; the remote install probe requires each one. */
export function relayArtifactFilenames(isWindows: boolean): string[] {
return RELAY_ARTIFACTS.filter(
(artifact) => !artifact.optional && (!artifact.windowsOnly || isWindows)
).map((artifact) => artifact.filename)
}
/** Artifacts a build may or may not emit. Hashed when present, never probed. */
export function relayOptionalArtifactFilenames(isWindows: boolean): string[] {
return RELAY_ARTIFACTS.filter(
(artifact) => artifact.optional && (!artifact.windowsOnly || isWindows)
).map((artifact) => artifact.filename)
}