fix(relay): open the real null device when detaching Windows stdio (#20808)

* fix(relay): open the real null device when detaching Windows stdio

`openSync('NUL')` does not reach the null device on Windows. node's fs runs
the path through `toNamespacedPath`, which resolves it against cwd and
prefixes `\\?\` — and that prefix turns off DOS device-name mapping, so
CreateFileW creates a regular file named `NUL` in the relay's install dir
and pins fds 0/1 to it instead of to a discard sink.

Verified on a Windows 11 host: `fs.openSync('NUL', 'w')` + a 5-byte write
produced a 5-byte file named `NUL` in cwd. `\\.\NUL` is passed through
`toNamespacedPath` verbatim; the same write discards and a read answers
EOF, with no file created.

It also escaped into shipped artifacts. release-cut.yml runs the relay
watcher fault harness with cwd = out/relay/win32-x64, so every Windows
installer since v1.4.169 carries `resources/relay/win32-x64/NUL`, which
NSIS extracts as `_NUL`.

* test(relay): prove the `\\?\` rewrite on a drive-letter path

`toNamespacedPath('NUL')` off Windows only resolves against a POSIX cwd and
stops; with no drive letter it never reaches the branch that adds `\\?\`. So
the assertion held for the wrong reason and did not demonstrate the rewrite
the comment describes. Assert it on an absolute drive path, which takes the
same branch on every host.
This commit is contained in:
Neil
2026-09-15 01:25:41 -07:00
committed by GitHub
parent f107499e44
commit c0fb04c8d2
2 changed files with 42 additions and 3 deletions
+29
View File
@@ -0,0 +1,29 @@
import { win32 } from 'node:path'
import { describe, expect, it } from 'vitest'
import { nullDevicePath } from './relay-primary-channel'
describe('nullDevicePath', () => {
it('names the POSIX null device off win32', () => {
expect(nullDevicePath('linux')).toBe('/dev/null')
expect(nullDevicePath('darwin')).toBe('/dev/null')
})
/**
* The defect this pins: `openSync('NUL')` on Windows does NOT open the null device.
* node runs the path through `toNamespacedPath`, which resolves it against cwd and
* prefixes `\\?\` — and `\\?\` turns off DOS device-name mapping, so CreateFileW makes
* a real file. v1.4.203's Windows installer shipped one at
* `resources/relay/win32-x64/NUL` because of it.
*/
it('uses a device path win32 cannot rewrite into a file in the relay cwd', () => {
const path = nullDevicePath('win32')
expect(path).toBe('\\\\.\\NUL')
expect(win32.toNamespacedPath(path)).toBe(path)
// Bare `NUL` never survives as a device name: it is resolved against cwd, and a
// drive-letter cwd then also takes the `\\?\` prefix. Spelled absolute because off
// Windows `resolve` finds no drive letter and stops before that second rewrite.
expect(win32.toNamespacedPath('NUL')).not.toBe('NUL')
expect(win32.toNamespacedPath(String.raw`C:\relay\NUL`)).toBe(String.raw`\\?\C:\relay\NUL`)
})
})
+13 -3
View File
@@ -2,6 +2,17 @@ import { closeSync, openSync } from 'node:fs'
import { RelayDispatcher } from './dispatcher'
import { RELAY_SENTINEL } from './protocol'
/**
* Why the `\\.\` device prefix and not bare `NUL`: node's fs resolves a relative path
* through `toNamespacedPath`, which hands CreateFileW a `\\?\C:\…\NUL` — and that prefix
* disables DOS device-name mapping, so the open creates a real FILE named `NUL` in the
* relay's cwd and pins fds 0/1 to it. One shipped in the 1.4.203 Windows installer as
* `resources/relay/win32-x64/NUL`. A `\\.\` path is passed through verbatim.
*/
export function nullDevicePath(platform: NodeJS.Platform = process.platform): string {
return platform === 'win32' ? String.raw`\\.\NUL` : '/dev/null'
}
export class RelayPrimaryChannel {
readonly dispatcher: RelayDispatcher
private stdoutAlive = true
@@ -111,14 +122,13 @@ export class RelayPrimaryChannel {
// Already closed by the peer.
}
}
const devNull = process.platform === 'win32' ? 'NUL' : '/dev/null'
try {
openSync(devNull, 'r')
openSync(nullDevicePath(), 'r')
} catch {
// Best-effort pin of the lowest free descriptor.
}
try {
openSync(devNull, 'w')
openSync(nullDevicePath(), 'w')
} catch {
// Best-effort pin of the next free descriptor.
}