Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions lib/src/host/remote/enroll-offer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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);
}
}

Expand Down
8 changes: 4 additions & 4 deletions lib/src/lib/mirrored-constants.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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),
);
Expand Down
14 changes: 8 additions & 6 deletions standalone/sidecar/pty-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
Expand All @@ -287,7 +289,7 @@ function applyShellIntegration(shell, env, shellArgs, integrationDir, runtime =

// WSL: only the standard `-d <distro>` 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
Expand Down
10 changes: 5 additions & 5 deletions standalone/sidecar/pty-core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});

Expand Down Expand Up @@ -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}'`]);
});

Expand All @@ -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}'`]);
});

Expand All @@ -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',
Expand All @@ -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}'`]);
});

Expand Down
4 changes: 3 additions & 1 deletion vscode-ext/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export async function tempStorageDir(): Promise<string> {
*/
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<void> {
Expand Down
71 changes: 55 additions & 16 deletions vscode-ext/test/peer-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<void>((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<void>((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();
Expand All @@ -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' })))
Expand Down Expand Up @@ -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)})`,
Expand Down Expand Up @@ -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, [
Expand All @@ -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)})`,
Expand Down Expand Up @@ -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)})`,
Expand All @@ -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)})`,
Expand Down Expand Up @@ -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<void>((resolve, reject) => {
server.once('error', reject);
server.listen(derivedSocketPath(), () => {
Expand Down Expand Up @@ -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<void>((resolve) => squatter.listen(path, resolve));

try {
Expand Down Expand Up @@ -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<void>((resolve) => squatter.listen(path, resolve));

try {
Expand All @@ -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());
Expand All @@ -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.
Expand Down
Loading