From 61ceb50b573c654677a116700299e3984812d363 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 1 Oct 2026 22:29:11 -0700 Subject: [PATCH] Use platform-native paths in Windows-target fixtures and peer-link tests Windows-target shell-integration and enroll-offer paths join with path.win32 regardless of the CI host, so the fixtures that assert them match on every runner. The peer-link suite uses a named pipe on Windows (with its own dead-pipe reclaim test), skips the Unix-inode corpse tests there, and prepares the Unix socket parent before binding. Split out of #892. Co-Authored-By: Claude Opus 5.5 --- lib/src/host/remote/enroll-offer.ts | 11 ++-- lib/src/lib/mirrored-constants.test.ts | 8 +-- standalone/sidecar/pty-core.js | 14 ++--- standalone/sidecar/pty-core.test.js | 10 ++-- vscode-ext/test/helpers.ts | 4 +- vscode-ext/test/peer-link.test.ts | 71 ++++++++++++++++++++------ 6 files changed, 81 insertions(+), 37 deletions(-) diff --git a/lib/src/host/remote/enroll-offer.ts b/lib/src/host/remote/enroll-offer.ts index 2c6be8d02..38b2bbc9a 100644 --- a/lib/src/host/remote/enroll-offer.ts +++ b/lib/src/host/remote/enroll-offer.ts @@ -16,7 +16,7 @@ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import * as path from 'node:path'; import { isEnrollmentOfferFresh, parseEnrollmentOffer, @@ -25,7 +25,7 @@ import { export type { EnrollmentOffer }; -const OFFER_FILE = join('run', 'enroll-offer.json'); +const OFFER_FILE = 'enroll-offer.json'; /** * Where each installer's offer lands, mirroring the install root that installer @@ -44,17 +44,18 @@ export function enrollmentOfferPath( env: NodeJS.ProcessEnv = process.env, home: string = homedir(), ): string | null { + const { join } = platform === 'win32' ? path.win32 : path.posix; switch (platform) { case 'darwin': - return join(home, 'Library', 'Application Support', 'Dormouse Relay', OFFER_FILE); + return join(home, 'Library', 'Application Support', 'Dormouse Relay', 'run', OFFER_FILE); case 'win32': // No `%LOCALAPPDATA%` is not a path to guess at: the installer joins onto // that variable, so without it this machine's install root is unknown. - return env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'Dormouse Relay', OFFER_FILE) : null; + return env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'Dormouse Relay', 'run', OFFER_FILE) : null; default: // `||` and not `??`, matching the installers' `${XDG_DATA_HOME:-…}`: an // empty value is unset, not a root at the filesystem's top. - return join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'dormouse-relay', OFFER_FILE); + return join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'dormouse-relay', 'run', OFFER_FILE); } } diff --git a/lib/src/lib/mirrored-constants.test.ts b/lib/src/lib/mirrored-constants.test.ts index 5ad87f0e1..1138196ce 100644 --- a/lib/src/lib/mirrored-constants.test.ts +++ b/lib/src/lib/mirrored-constants.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, resolve, win32 } from 'node:path'; import { describe, expect, it } from 'vitest'; import { PAIRING_OUTCOME_COPY, @@ -137,12 +137,12 @@ describe('enrollment-offer path mirrors the installers', () => { const source = readRepoFile(file); const variable = extract(source, file, /^\$INSTALL_ROOT = Join-Path \$env:(\w+) '[^']+'$/m); const local = 'C:\\Users\\ned\\AppData\\Local'; - const root = join( + const root = win32.join( local, extract(source, file, /^\$INSTALL_ROOT = Join-Path \$env:\w+ '([^']+)'$/m), ); - const run = join(root, extract(source, file, /^\$RUN_DIR = Join-Path \$INSTALL_ROOT '([^']+)'$/m)); - const offerFile = join( + const run = win32.join(root, extract(source, file, /^\$RUN_DIR = Join-Path \$INSTALL_ROOT '([^']+)'$/m)); + const offerFile = win32.join( run, extract(source, file, /^\$ENROLL_OFFER_FILE = Join-Path \$RUN_DIR '([^']+)'$/m), ); diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index bd2709448..ac5edfcd7 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -209,7 +209,8 @@ function withoutInheritedMsysOriginalPath(env, platform = process.platform) { // glob); `DORMOUSE_SHELL_INTEGRATION_DIR` overrides it for hosts that stage the // sidecar elsewhere (e.g. the VS Code bundle) and for tests. function resolveShellIntegrationDir(env, runtime = {}) { - return env.DORMOUSE_SHELL_INTEGRATION_DIR || path.join(runtime.dirname || __dirname, 'shell-integration'); + const platformPath = (runtime.platform || process.platform) === 'win32' ? path.win32 : path.posix; + return env.DORMOUSE_SHELL_INTEGRATION_DIR || platformPath.join(runtime.dirname || __dirname, 'shell-integration'); } // Basename of a shell path, lowercased and with any `.exe` dropped, handling @@ -258,11 +259,12 @@ function winPathToWslMount(winPath) { // their shell. bash is the only WSL shell we integrate for now. function applyShellIntegration(shell, env, shellArgs, integrationDir, runtime = {}) { const fsModule = runtime.fsModule || fs; + const platformPath = (runtime.platform || process.platform) === 'win32' ? path.win32 : path.posix; const stem = shellStem(shell); if (stem === 'zsh') { - const zshDir = path.join(integrationDir, 'zsh'); - if (fileExists(path.join(zshDir, '.zshrc'), fsModule)) { + const zshDir = platformPath.join(integrationDir, 'zsh'); + if (fileExists(platformPath.join(zshDir, '.zshrc'), fsModule)) { return { env: { ...env, ZDOTDIR: zshDir, USER_ZDOTDIR: env.ZDOTDIR || env.HOME || '' }, shellArgs, @@ -271,14 +273,14 @@ function applyShellIntegration(shell, env, shellArgs, integrationDir, runtime = } if (stem === 'bash' && bashArgsAreInjectable(shellArgs)) { - const script = path.join(integrationDir, 'bash', 'shellIntegration.bash'); + const script = platformPath.join(integrationDir, 'bash', 'shellIntegration.bash'); if (fileExists(script, fsModule)) { return { env, shellArgs: ['--init-file', script] }; } } if (stem === 'pwsh' || stem === 'powershell') { - const script = path.join(integrationDir, 'pwsh', 'shellIntegration.ps1'); + const script = platformPath.join(integrationDir, 'pwsh', 'shellIntegration.ps1'); if (fileExists(script, fsModule)) { const integratedArgs = powerShellIntegratedArgs(shellArgs, script); if (integratedArgs) return { env, shellArgs: integratedArgs }; @@ -287,7 +289,7 @@ function applyShellIntegration(shell, env, shellArgs, integrationDir, runtime = // WSL: only the standard `-d ` launch (the shape the picker emits). if (stem === 'wsl' && shellArgs.length === 2 && shellArgs[0] === '-d') { - const script = path.join(integrationDir, 'bash', 'shellIntegration.bash'); + const script = platformPath.join(integrationDir, 'bash', 'shellIntegration.bash'); const mount = winPathToWslMount(script); if (mount && fileExists(script, fsModule)) { // A `sh -c` detector, passed as one argv element so node-pty hands it to diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 94cf5ee50..7d467141b 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -1075,7 +1075,7 @@ test('resolveSpawnConfig injects bash integration for Git Bash despite its --log // The --login -i defaults are subsumed by the init-file script, which sources // the login profile itself. - const script = path.join(integrationDir, 'bash', 'shellIntegration.bash'); + const script = path.win32.join(integrationDir, 'bash', 'shellIntegration.bash'); assert.deepEqual(config.shellArgs, ['--init-file', script]); }); @@ -1128,7 +1128,7 @@ test('resolveSpawnConfig injects pwsh integration via -NoExit -Command dot-sourc }, ); - const script = path.join(integrationDir, 'pwsh', 'shellIntegration.ps1'); + const script = path.win32.join(integrationDir, 'pwsh', 'shellIntegration.ps1'); assert.deepEqual(config.shellArgs, ['-NoExit', '-Command', `. '${script}'`]); }); @@ -1149,7 +1149,7 @@ test('resolveSpawnConfig injects Windows PowerShell (powershell.exe) too', () => }, ); - const script = path.join(integrationDir, 'pwsh', 'shellIntegration.ps1'); + const script = path.win32.join(integrationDir, 'pwsh', 'shellIntegration.ps1'); assert.deepEqual(config.shellArgs, ['-NoExit', '-Command', `. '${script}'`]); }); @@ -1172,7 +1172,7 @@ test('resolveSpawnConfig merges integration into an interactive pwsh -Command (e ); // The dev-shell command runs first, then our dot-source installs the prompt wrapper. - const script = path.join(integrationDir, 'pwsh', 'shellIntegration.ps1'); + const script = path.win32.join(integrationDir, 'pwsh', 'shellIntegration.ps1'); assert.deepEqual(config.shellArgs, [ '-NoExit', '-Command', @@ -1195,7 +1195,7 @@ test('resolveSpawnConfig adds a -Command to an interactive pwsh launch that has }, ); - const script = path.join(integrationDir, 'pwsh', 'shellIntegration.ps1'); + const script = path.win32.join(integrationDir, 'pwsh', 'shellIntegration.ps1'); assert.deepEqual(config.shellArgs, ['-NoExit', '-NoLogo', '-Command', `. '${script}'`]); }); diff --git a/vscode-ext/test/helpers.ts b/vscode-ext/test/helpers.ts index 8ce8520a3..e56f6769e 100644 --- a/vscode-ext/test/helpers.ts +++ b/vscode-ext/test/helpers.ts @@ -33,7 +33,9 @@ export async function tempStorageDir(): Promise { */ export function derivedSocketPath(storageDir: string): string { const id = createHash('sha256').update(storageDir).digest('hex').slice(0, 12); - return join(tmpdir(), `dormouse-peer-${process.getuid?.() ?? 0}`, `${id}.sock`); + return process.platform === 'win32' + ? `\\\\.\\pipe\\dormouse-peer-${id}` + : join(tmpdir(), `dormouse-peer-${process.getuid?.() ?? 0}`, `${id}.sock`); } export async function removeDir(dir: string): Promise { diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 1913b0199..d4767111b 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -170,6 +170,44 @@ afterEach(async () => { }); describe('bind-as-lease', () => { + it.skipIf(process.platform !== 'win32')('reclaims a dead named pipe and elects exactly one broker for racing windows', async () => { + // Windows removes the pipe when its process dies; there is no Unix corpse + // inode to unlink. Exercise the actual transport, not a fake socket path. + const corpse = spawn(process.execPath, ['-e', + "require('node:net').createServer().listen(process.argv[1], () => process.send('listening'))", + derivedSocketPath()], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); + try { + await new Promise((resolve, reject) => { + corpse.once('message', () => resolve()); + corpse.once('error', reject); + corpse.once('exit', (code) => reject(new Error(`pipe fixture exited ${code}`))); + }); + const exited = new Promise((resolve) => corpse.once('exit', () => resolve())); + corpse.kill('SIGKILL'); + await exited; + const firstSide = fakeWindow({ entries: [{ surfaceId: 'first' }] }); + const secondSide = fakeWindow({ entries: [{ surfaceId: 'second' }] }); + const first = await openWindow(firstSide); + const second = await openWindow(secondSide); + const roles: boolean[] = []; + await Promise.all([ + first.ensurePeerNet((held) => roles.push(held)), + second.ensurePeerNet((held) => roles.push(held)), + ]); + const brokers = [first, second].filter((mod) => mod.isPeerBroker()); + expect(brokers).toHaveLength(1); + expect(roles).toEqual([true]); + const expected = first.isPeerBroker() ? secondSide.entries : firstSide.entries; + await waitFor(async () => JSON.stringify(await brokers[0].remoteRequest('directory', {})) === JSON.stringify(expected)); + } finally { + if (corpse.exitCode === null && corpse.signalCode === null) { + const exited = new Promise((resolve) => corpse.once('exit', () => resolve())); + corpse.kill('SIGKILL'); + await exited; + } + } + }, 15_000); + it('rejects when the peer socket cannot be bound', async () => { const mod = await openWindow(fakeWindow()); const failingServer = createServer(); @@ -184,7 +222,8 @@ describe('bind-as-lease', () => { // libuv callback with nothing to catch it. const mod = await openWindow(fakeWindow()); const server = createServer(); - const path = join(dir, 'accept-error.sock'); + const path = derivedSocketPath(); + if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await mod.listenServer(server, path); try { expect(() => server.emit('error', Object.assign(new Error('EMFILE'), { code: 'EMFILE' }))) @@ -258,13 +297,13 @@ describe('bind-as-lease', () => { releaseStuck(); }, 15_000); - it('does not answer broker while a reclaimed bind is still unverified', async () => { + it.skipIf(process.platform === 'win32')('does not answer broker while a reclaimed bind is still unverified', async () => { // `stillOurs` spends 250 ms watching for a window that cleared the same // corpse and bound after us. An enroll landing inside that window used to // see a bound socket, start a service, and the stand-down path // (`closeServer(false)`) never tears one down — two Burrows under one burrowId. const path = derivedSocketPath(); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); const corpse = spawn(process.execPath, [ '-e', `require('node:net').createServer().listen(${JSON.stringify(path)})`, @@ -382,9 +421,9 @@ describe('bind-as-lease', () => { expect(roles).toEqual([true, true]); }); - it('takes over a socket whose broker died without unlinking it', async () => { + it.skipIf(process.platform === 'win32')('takes over a socket whose broker died without unlinking it', async () => { const path = derivedSocketPath(); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); // A killed process leaves the inode behind — `close()` would unlink it, so // the only way to produce this state is to not let the owner close. const corpse = spawn(process.execPath, [ @@ -405,13 +444,13 @@ describe('bind-as-lease', () => { expect(mod.isPeerBroker()).toBe(true); }); - it('re-binds when the socket it reclaimed is unlinked out from under it', async () => { + it.skipIf(process.platform === 'win32')('re-binds when the socket it reclaimed is unlinked out from under it', async () => { // Two windows can clear the same corpse and the second bind displaces the // first without any error — the loser keeps serving an inode no client can // reach. On unix a path that has *gone* after our bind is the same failure, // and reading it as "still ours" leaves a broker nobody can dial. const path = derivedSocketPath(); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); const corpse = spawn(process.execPath, [ '-e', `require('node:net').createServer().listen(${JSON.stringify(path)})`, @@ -446,13 +485,13 @@ describe('bind-as-lease', () => { expect(peer.isPeerBroker()).toBe(false); }, 30_000); - it('settles two windows racing for one corpse into a broker and a client', async () => { + it.skipIf(process.platform === 'win32')('settles two windows racing for one corpse into a broker and a client', async () => { // Both find the same dead socket, both may unlink it, and the second bind // silently displaces the first. Whoever loses that has to notice and stand // down rather than serve an inode nobody can reach — and must then end up a // client, not wedged. const path = derivedSocketPath(); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); const corpse = spawn(process.execPath, [ '-e', `require('node:net').createServer().listen(${JSON.stringify(path)})`, @@ -478,14 +517,14 @@ describe('bind-as-lease', () => { expect(firstRoles.concat(secondRoles)).toEqual([true]); }, 30_000); - it('stands down when a competing reclaim displaces it before its verification reads the path', async () => { + it.skipIf(process.platform === 'win32')('stands down when a competing reclaim displaces it before its verification reads the path', async () => { // The interleaving the racing test above reaches only by luck, forced: a // competing window's unlink and rebind land after our bind but before // `stillOurs` first reads the path. Anchored to that read rather than to // our own bind, both windows would name the competitor's socket as "ours" // and both would broker. const path = derivedSocketPath(); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); const corpse = spawn(process.execPath, [ '-e', `require('node:net').createServer().listen(${JSON.stringify(path)})`, @@ -878,7 +917,7 @@ describe('bind-as-lease', () => { } }); }); - await mkdir(dirname(derivedSocketPath()), { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await mkdir(dirname(derivedSocketPath()), { recursive: true, mode: 0o700 }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(derivedSocketPath(), () => { @@ -1205,7 +1244,7 @@ describe('peer handshake', () => { })(); }); const path = derivedSocketPath(); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await new Promise((resolve) => squatter.listen(path, resolve)); try { @@ -1245,7 +1284,7 @@ describe('peer handshake', () => { socket.write('null\n'); }); const path = derivedSocketPath(); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await new Promise((resolve) => squatter.listen(path, resolve)); try { @@ -1266,7 +1305,7 @@ describe('peer handshake', () => { } }); - it('keeps the socket directory private to this user', async () => { + it.skipIf(process.platform === 'win32')('keeps the socket directory private to this user', async () => { // The layer below the handshake: in a shared tmpdir, a directory anyone can // write to is one where a co-resident user can create the path first. const peerDir = dirname(derivedSocketPath()); @@ -1281,7 +1320,7 @@ describe('peer handshake', () => { expect((await stat(peerDir)).mode & 0o777).toBe(0o700); }); - it('stands down for good when the socket directory is not one', async () => { + it.skipIf(process.platform === 'win32')('stands down for good when the socket directory is not one', async () => { // Something else holds the only place these sockets may live. No amount of // retrying changes that, so the link stops rather than spinning — and the // waiting caller is released rather than left hanging.