From 0bd6f220df015fad10d3a35cb5aaf4eae7fda9e5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:10:51 -0700 Subject: [PATCH] fix(relay): assign Windows kill-on-close job --- src/relay/relay-daemon.ts | 2 + src/relay/relay-windows-pty-job.test.ts | 103 ++++++++++++++++++++++++ src/relay/relay-windows-pty-job.ts | 53 ++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/relay/relay-windows-pty-job.test.ts create mode 100644 src/relay/relay-windows-pty-job.ts diff --git a/src/relay/relay-daemon.ts b/src/relay/relay-daemon.ts index 6d4968c2ea9..139175a43e6 100644 --- a/src/relay/relay-daemon.ts +++ b/src/relay/relay-daemon.ts @@ -3,6 +3,7 @@ import { readLaunchVersion } from './relay-handshake' import type { RelayLaunchOptions } from './relay-launch-options' import { RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS, RELAY_IDLE_GRACE_MS } from './relay-launch-options' import { relayLogLine } from './relay-diagnostic-log' +import { assignRelayProcessToKillOnCloseJob } from './relay-windows-pty-job' import { RelayPrimaryChannel } from './relay-primary-channel' import { RelayRuntimeServices } from './relay-runtime-services' import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' @@ -18,6 +19,7 @@ export async function runRelayDaemon( if (options.detached && options.logFile) { installRelayLogRotation(options.logFile) } + assignRelayProcessToKillOnCloseJob() const socketOwnership = new RelaySocketOwnership(options.sockPath) process.on('uncaughtException', (error) => { diff --git a/src/relay/relay-windows-pty-job.test.ts b/src/relay/relay-windows-pty-job.test.ts new file mode 100644 index 00000000000..03645fb2da1 --- /dev/null +++ b/src/relay/relay-windows-pty-job.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'node:fs' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { assignRelayProcessToKillOnCloseJob } from './relay-windows-pty-job' + +afterEach(() => { + vi.restoreAllMocks() +}) + +function captureRelayLog(): { lines: () => string } { + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + return { + lines: () => write.mock.calls.map(([line]) => String(line)).join('') + } +} + +describe('assignRelayProcessToKillOnCloseJob', () => { + it('reports successful Windows assignment without a degradation log', () => { + const log = captureRelayLog() + const assignCurrentProcessToJob = vi.fn().mockReturnValue(true) + + expect(assignRelayProcessToKillOnCloseJob('win32', () => ({ assignCurrentProcessToJob }))).toBe( + 'assigned' + ) + expect(assignCurrentProcessToJob).toHaveBeenCalledOnce() + expect(log.lines()).toBe('') + }) + + it('keeps startup available and logs when the native export is absent', () => { + const log = captureRelayLog() + + expect(assignRelayProcessToKillOnCloseJob('win32', () => ({}))).toBe('unavailable') + expect(log.lines()).toContain( + '[relay] Windows kill-on-close job unavailable: ConPTY addon does not export assignCurrentProcessToJob' + ) + }) + + it('keeps startup available and logs when Windows refuses assignment', () => { + const log = captureRelayLog() + + expect( + assignRelayProcessToKillOnCloseJob('win32', () => ({ + assignCurrentProcessToJob: () => false + })) + ).toBe('unavailable') + expect(log.lines()).toContain( + '[relay] Windows kill-on-close job unavailable: assignment was refused' + ) + }) + + it.each([ + ['native load', () => new Error('binding unavailable')], + ['native assignment', () => new Error('job nesting denied')] + ])('keeps startup available when %s throws', (phase, createError) => { + const log = captureRelayLog() + const loader = + phase === 'native load' + ? () => { + throw createError() + } + : () => ({ + assignCurrentProcessToJob: () => { + throw createError() + } + }) + + expect(assignRelayProcessToKillOnCloseJob('win32', loader)).toBe('unavailable') + expect(log.lines()).toContain(createError().message) + }) + + it.each(['darwin', 'linux'] as const)('does nothing on %s', (platform) => { + const log = captureRelayLog() + const loadNative = vi.fn() + + expect(assignRelayProcessToKillOnCloseJob(platform, loadNative)).toBe('not-applicable') + expect(loadNative).not.toHaveBeenCalled() + expect(log.lines()).toBe('') + }) +}) + +describe('relay daemon startup ordering', () => { + it('assigns exactly once before relay ownership or request handling begins', () => { + const source = readFileSync(new URL('./relay-daemon.ts', import.meta.url), 'utf8') + const body = source.slice( + source.indexOf('export async function runRelayDaemon'), + source.indexOf('function registerRelayStatus') + ) + const assignment = 'assignRelayProcessToKillOnCloseJob()' + const assignmentIndex = body.indexOf(assignment) + + expect(body.split(assignment)).toHaveLength(2) + expect(assignmentIndex).toBeGreaterThan( + body.indexOf('installRelayLogRotation(options.logFile)') + ) + for (const initialization of [ + 'new RelaySocketOwnership(', + 'new RelayPrimaryChannel(', + 'new RelayRuntimeServices(', + 'new RelayReconnectListener(' + ]) { + expect(assignmentIndex).toBeLessThan(body.indexOf(initialization)) + } + }) +}) diff --git a/src/relay/relay-windows-pty-job.ts b/src/relay/relay-windows-pty-job.ts new file mode 100644 index 00000000000..4a0e9f20ec6 --- /dev/null +++ b/src/relay/relay-windows-pty-job.ts @@ -0,0 +1,53 @@ +import { createRequire } from 'node:module' +import { relayLogLine } from './relay-diagnostic-log' + +type ConptyNative = { + assignCurrentProcessToJob: () => boolean +} + +export type RelayWindowsPtyJobOutcome = 'assigned' | 'unavailable' | 'not-applicable' + +const requireFromRelay = createRequire(__filename) + +function loadConptyNative(): unknown { + const { loadNativeModule } = requireFromRelay('node-pty/lib/utils') as { + loadNativeModule: (name: string) => { module: unknown } + } + return loadNativeModule('conpty').module +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function reportUnavailable(reason: string): RelayWindowsPtyJobOutcome { + relayLogLine(`[relay] Windows kill-on-close job unavailable: ${reason}`) + return 'unavailable' +} + +export function assignRelayProcessToKillOnCloseJob( + platform: NodeJS.Platform = process.platform, + loadNative: () => unknown = loadConptyNative +): RelayWindowsPtyJobOutcome { + if (platform !== 'win32') { + return 'not-applicable' + } + + let native: Partial | null + try { + native = loadNative() as Partial | null + } catch (error) { + return reportUnavailable(`ConPTY addon failed to load: ${errorMessage(error)}`) + } + if (typeof native?.assignCurrentProcessToJob !== 'function') { + return reportUnavailable('ConPTY addon does not export assignCurrentProcessToJob') + } + + try { + return native.assignCurrentProcessToJob() + ? 'assigned' + : reportUnavailable('assignment was refused') + } catch (error) { + return reportUnavailable(`assignment failed: ${errorMessage(error)}`) + } +}