Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

stop using tmpdir() due to runtime inconsistencies #21

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
10 changes: 8 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import * as vscode from "vscode";

import { NativeIo } from "./nativeIo";
import CommandRunner from "./commandRunner";
import { FallbackIo } from "./fallbackIo";
import { getLegacyCommunicationDirPath } from "./legacyPaths";
import { NativeIo } from "./nativeIo";
import { getCommunicationDirPath } from "./getCommunicationDirPath";
import { FocusedElementType } from "./types";

export async function activate(context: vscode.ExtensionContext) {
const io = new NativeIo();
const io = new FallbackIo([
new NativeIo(getCommunicationDirPath()),
new NativeIo(getLegacyCommunicationDirPath()),
]);
await io.initialize();

const commandRunner = new CommandRunner(io);
Expand Down
133 changes: 133 additions & 0 deletions src/fallbackIo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Io, SignalReader } from "./io";
import { Request, Response } from "./types";

class FallbackInboundSignal implements SignalReader {
private signals: SignalReader[];

constructor(name: string, ios: Io[]) {
this.signals = ios.map((io) => io.getInboundSignal(name));
}

async getVersion(): Promise<string | null> {
let error: Error | undefined;

for (const signal of this.signals) {
try {
const version = await signal.getVersion();
if (version != null) {
return version;
}
} catch (err) {
error ??= err as Error;
}
}

if (error != null) {
throw error;
}

return null;
}
}

/**
* An {@link Io} that tries to read from multiple {@link Io}s in order until it
* finds one that works. If none of them work, it throws the error from the
* highest-priority {@link Io}. For some methods, it stops after the highest
* priority {@link Io} that succeeds; for other methods it runs all of them.
*
* As an optimization, it removes all {@link Io}s after the highest-priority
* {@link Io} that has had an active request.
*/
export class FallbackIo implements Io {
/** The {@link IO} from which we successfully read a request */
private activeIo: Io | null = null;

/**
* The index of the highest-priority IO that has had an active request. If no IO
* has had an active request, this is integer max
*/
private highestPriorityActiveIoIndex = Number.MAX_SAFE_INTEGER - 1;

constructor(private ioList: Io[]) {}

initialize(): Promise<void> {
return safeRunAll(this.ioList, (io) => io.initialize());
}

prepareResponse(): Promise<void> {
// As an optimization, remove all IOs after the highest-priority IO that has
// had an active request
this.ioList.splice(this.highestPriorityActiveIoIndex + 1);

return safeRunAll(this.ioList, (io) => io.prepareResponse());
}

closeResponse(): Promise<void> {
return safeRunAll(this.ioList, (io) => io.closeResponse());
}

async readRequest(): Promise<Request> {
// Note that unlike the methods above, we stop after the first successful
// read because only one IO should be successful

/** The error from the highest priority IO */
let firstError: Error | undefined;

for (let i = 0; i < this.ioList.length; i++) {
const io = this.ioList[i];
try {
const request = await io.readRequest();
this.activeIo = io;
this.highestPriorityActiveIoIndex = i;
return request;
} catch (err) {
firstError ??= err as Error;
}
}

throw firstError;
}

async writeResponse(response: Response): Promise<void> {
if (this.activeIo == null) {
throw new Error("No active IO; this shouldn't happen");
}
// Only respond to the IO that had the active request
await this.activeIo.writeResponse(response);
this.activeIo = null;
}

getInboundSignal(name: string): SignalReader {
return new FallbackInboundSignal(name, this.ioList);
}
}

/**
* Calls {@link fn} for each item in {@link items}, catching any errors and
* throwing the first one after all items have been processed if none of them
* succeeded.
*
* @param items The items to iterate over
* @param fn The function to call for each item
*/
async function safeRunAll<T>(
items: T[],
fn: (item: T) => Promise<void>
): Promise<void> {
let firstError: Error | undefined;
let success = false;

for (const item of items) {
try {
await fn(item);
success = true;
} catch (err) {
firstError ??= err as Error;
}
}

if (!success) {
throw firstError;
}
}
17 changes: 17 additions & 0 deletions src/getCommunicationDirPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { homedir } from "os";
import { join } from "path";

export function getCommunicationDirPath() {
// NB: See https://github.com/talonhub/community/issues/966 for lots of
// discussion about this path
if (process.platform === "linux" || process.platform === "darwin") {
return join(homedir(), ".talon/.comms/vscode-command-server");
} else if (process.platform === "win32") {
return join(
homedir(),
"\\AppData\\Roaming\\talon\\.comms\\vscode-command-server"
);
} else {
throw new Error(`Unsupported platform: ${process.platform}`);
}
}
12 changes: 6 additions & 6 deletions src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@ export interface SignalReader {
}

export interface Io {
initialize: () => Promise<void>;
initialize(): Promise<void>;
// Prepares to send a response to readRequest, preventing any other process
// from doing so until closeResponse is called. Throws an error if called
// twice before closeResponse.
prepareResponse: () => Promise<void>;
prepareResponse(): Promise<void>;
// Closes a prepared response, allowing other processes to respond to
// readRequest. Throws an error if the prepareResponse has not been called.
closeResponse: () => Promise<void>;
closeResponse(): Promise<void>;
// Returns a request from Talon command client.
readRequest: () => Promise<Request>;
readRequest(): Promise<Request>;
// Writes a response. Throws an error if prepareResponse has not been called.
writeResponse: (response: Response) => Promise<void>;
writeResponse(response: Response): Promise<void>;
// Returns a SignalReader.
getInboundSignal: (name: string) => SignalReader;
getInboundSignal(name: string): SignalReader;
}
12 changes: 12 additions & 0 deletions src/legacyPaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { tmpdir, userInfo } from "os";
import { join } from "path";

export function getLegacyCommunicationDirPath() {
const info = userInfo();

// NB: On Windows, uid < 0, and the tmpdir is user-specific, so we don't
// bother with a suffix
const suffix = info.uid >= 0 ? `-${info.uid}` : "";

return join(tmpdir(), `vscode-command-server${suffix}`);
}
55 changes: 31 additions & 24 deletions src/nativeIo.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import { mkdirSync, lstatSync } from "fs";
import { join } from "path";
import { S_IWOTH } from "constants";
import {
getCommunicationDirPath,
getRequestPath,
getResponsePath,
getSignalDirPath,
} from "./paths";
import { userInfo } from "os";
import { Io } from "./io";
import { lstatSync, mkdirSync } from "fs";
import { FileHandle, open, readFile, stat } from "fs/promises";
import { userInfo } from "os";
import { join } from "path";
import { VSCODE_COMMAND_TIMEOUT_MS } from "./constants";
import { Io } from "./io";
import { Request, Response } from "./types";

const MAX_SIGNAL_VERSION_AGE_MS = 60 * 1000;

class InboundSignal {
constructor(private path: string) {}

Expand All @@ -25,7 +21,15 @@ class InboundSignal {
*/
async getVersion() {
try {
return (await stat(this.path)).mtimeMs.toString();
const { mtimeMs } = await stat(this.path);

if (
Math.abs(mtimeMs - new Date().getTime()) > MAX_SIGNAL_VERSION_AGE_MS
) {
return null;
}

return mtimeMs.toString();
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
Expand All @@ -38,18 +42,23 @@ class InboundSignal {

export class NativeIo implements Io {
private responseFile: FileHandle | null;
private signalDirPath: string;
private requestPath: string;
private responsePath: string;

constructor() {
constructor(private communicationDirPath: string) {
this.responseFile = null;

this.signalDirPath = join(communicationDirPath, "signals");
this.requestPath = join(communicationDirPath, "request.json");
this.responsePath = join(communicationDirPath, "response.json");
}

async initialize(): Promise<void> {
const communicationDirPath = getCommunicationDirPath();

console.debug(`Creating communication dir ${communicationDirPath}`);
mkdirSync(communicationDirPath, { recursive: true, mode: 0o770 });
console.debug(`Creating communication dir ${this.communicationDirPath}`);
mkdirSync(this.communicationDirPath, { recursive: true, mode: 0o770 });

const stats = lstatSync(communicationDirPath);
const stats = lstatSync(this.communicationDirPath);

const info = userInfo();

Expand All @@ -61,7 +70,7 @@ export class NativeIo implements Io {
(info.uid >= 0 && stats.uid !== info.uid)
) {
throw new Error(
`Refusing to proceed because of invalid communication dir ${communicationDirPath}`
`Refusing to proceed because of invalid communication dir ${this.communicationDirPath}`
);
}
}
Expand All @@ -70,7 +79,7 @@ export class NativeIo implements Io {
if (this.responseFile) {
throw new Error("response is already locked");
}
this.responseFile = await open(getResponsePath(), "wx");
this.responseFile = await open(this.responsePath, "wx");
}

async closeResponse(): Promise<void> {
Expand All @@ -86,10 +95,8 @@ export class NativeIo implements Io {
* @returns A promise that resolves to a Response object
*/
async readRequest(): Promise<Request> {
const requestPath = getRequestPath();

const stats = await stat(requestPath);
const request = JSON.parse(await readFile(requestPath, "utf-8"));
const stats = await stat(this.requestPath);
const request = JSON.parse(await readFile(this.requestPath, "utf-8"));

if (
Math.abs(stats.mtimeMs - new Date().getTime()) > VSCODE_COMMAND_TIMEOUT_MS
Expand All @@ -115,7 +122,7 @@ export class NativeIo implements Io {
}

getInboundSignal(name: string) {
const signalDir = getSignalDirPath();
const signalDir = this.signalDirPath;
const path = join(signalDir, name);
return new InboundSignal(path);
}
Expand Down
24 changes: 0 additions & 24 deletions src/paths.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/uninstall.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getCommunicationDirPath } from "./paths";
import { getCommunicationDirPath } from "./getCommunicationDirPath";
import { sync as rimrafSync } from "rimraf";

function main() {
Expand Down