Skip to content

Commit

Permalink
Codespaces compatibility (#27)
Browse files Browse the repository at this point in the history
  • Loading branch information
jcheng5 authored Mar 4, 2024
1 parent 31f494a commit 6bdac65
Show file tree
Hide file tree
Showing 9 changed files with 405 additions and 242 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## 0.1.6

- "Run Shiny App" now works with Python executable paths that have spaces or other special characters. (#26)
- "Run Shiny App" now starts a fresh console for each run (and closes the last console it started), so that the app's output is not mixed with the output of previous runs. (#27)
- Improved compatibility with GitHub Codespaces. (#27)

## 0.1.5

Expand Down
13 changes: 10 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@
"shiny.python.port": {
"type": "integer",
"default": 0,
"description": "The port number to listen on when running an app. (Use 0 to choose a random port for each workspace.)"
"description": "The port number Shiny should listen on when running an app. (Use 0 to choose a random port.)"
},
"shiny.python.autoreloadPort": {
"type": "integer",
"default": 0,
"description": "The port number Shiny should use for a supplemental WebSocket channel it uses to support reload-on-save. (Use 0 to choose a random port.)"
},
"shiny.python.debugJustMyCode": {
"type": "boolean",
Expand All @@ -89,7 +94,6 @@
"@types/vscode": "^1.66.0",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@vscode/python-extension": "^1.0.5",
"@vscode/test-electron": "^2.1.3",
"eslint": "^8.11.0",
"glob": "^10.3.10",
Expand All @@ -98,5 +102,8 @@
},
"extensionDependencies": [
"ms-python.python"
]
],
"dependencies": {
"@vscode/python-extension": "^1.0.5"
}
}
3 changes: 0 additions & 3 deletions src/constants.ts

This file was deleted.

8 changes: 2 additions & 6 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,8 @@ import { runApp, debugApp, onDidStartDebugSession } from "./run";

export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("shiny.python.runApp", () =>
runApp(context)
),
vscode.commands.registerCommand("shiny.python.debugApp", () =>
debugApp(context)
)
vscode.commands.registerCommand("shiny.python.runApp", runApp),
vscode.commands.registerCommand("shiny.python.debugApp", debugApp)
);

const throttledUpdateContext = new Throttler(2000, updateContext);
Expand Down
122 changes: 122 additions & 0 deletions src/net-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as http from "http";
import * as net from "net";
import { AddressInfo } from "net";
import * as vscode from "vscode";
import { getRemoteSafeUrl } from "./extension-api-utils/getRemoteSafeUrl";
import { retryUntilTimeout } from "./retry-utils";

/**
* Tests if a port is open on a host, by trying to connect to it with a TCP
* socket.
*/
async function isPortOpen(
host: string,
port: number,
timeout: number = 1000
): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
const client = new net.Socket();

client.setTimeout(timeout);
client.connect(port, host, function () {
resolve(true);
client.end();
});

client.on("timeout", () => {
client.destroy();
reject(new Error("Timed out"));
});

client.on("error", (err) => {
reject(err);
});

client.on("close", () => {
reject(new Error("Connection closed"));
});
});
}

/**
* Opens a browser for the specified port, once that port is open. Handles
* translating http://localhost:<port> into a proxy URL, if necessary.
* @param port The port to open the browser for.
* @param additionalPorts Additional ports to wait for before opening the
* browser.
*/
export async function openBrowserWhenReady(
port: number,
...additionalPorts: number[]
): Promise<void> {
const portsOpen = [port, ...additionalPorts].map((p) =>
retryUntilTimeout(10000, () => isPortOpen("127.0.0.1", p))
);
const portsOpenResult = await Promise.all(portsOpen);
if (portsOpenResult.filter((p) => !p).length > 0) {
console.warn("Failed to connect to Shiny app, not launching browser");
return;
}

let previewUrl = await getRemoteSafeUrl(port);
await openBrowser(previewUrl);
}

export async function openBrowser(url: string): Promise<void> {
// if (process.env["CODESPACES"] === "true") {
// vscode.env.openExternal(vscode.Uri.parse(url));
// } else {
await vscode.commands.executeCommand("simpleBrowser.api.open", url, {
preserveFocus: true,
viewColumn: vscode.ViewColumn.Beside,
});
// }
}

export async function suggestPort(): Promise<number> {
do {
const server = http.createServer();

const p = new Promise<number>((resolve, reject) => {
server.on("listening", () =>
resolve((server.address() as AddressInfo).port)
);
server.on("error", reject);
}).finally(() => {
return closeServer(server);
});

server.listen(0, "127.0.0.1");

const port = await p;

if (!UNSAFE_PORTS.includes(port)) {
return port;
}
} while (true);
}

async function closeServer(server: http.Server): Promise<void> {
return new Promise<void>((resolve, reject) => {
server.close((errClose) => {
if (errClose) {
// Don't bother logging, we don't care (e.g. if the server
// failed to listen, close() will fail)
}
// Whether close succeeded or not, we're now ready to continue
resolve();
});
});
}

// Ports that are considered unsafe by Chrome
// http://superuser.com/questions/188058/which-ports-are-considered-unsafe-on-chrome
// https://github.com/rstudio/shiny/issues/1784
const UNSAFE_PORTS = [
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79,
87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137,
139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723,
2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697,
10080,
];
37 changes: 37 additions & 0 deletions src/port-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as vscode from "vscode";
import { suggestPort } from "./net-utils";

const transientPorts: Record<string, number | undefined> = {};

export async function getAppPort(reason: "run" | "debug"): Promise<number> {
return (
// Port can be zero, which means random assignment
vscode.workspace.getConfiguration("shiny.python").get("port") ||
(await defaultPort(`app_${reason}`))
);
}

export async function getAutoreloadPort(
reason: "run" | "debug"
): Promise<number> {
return (
// Port can be zero, which means random assignment
vscode.workspace.getConfiguration("shiny.python").get("autoreloadPort") ||
(await defaultPort(`autoreload_${reason}`))
);
}

async function defaultPort(portCacheKey: string): Promise<number> {
// Retrieve most recently used port
let port: number = transientPorts[portCacheKey] ?? 0;
if (port !== 0) {
return port;
}

port = await suggestPort();

// Save for next time
transientPorts[portCacheKey] = port;

return port;
}
25 changes: 22 additions & 3 deletions src/retry-utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
/**
* Repeatedly calls callback until it executes without throwing an error, or
* until timeoutMs has passed.
* @param timeoutMs Number of milliseconds to wait before giving up.
* @param callback The function to call repeatedly.
* @returns The return value of the first successful call to the callback
* (doesn't have to be truthy, just not erroring); or undefined if the timeout
* was reached.
*/
export async function retryUntilTimeout<T>(
timeoutMs: number,
callback: () => Promise<T>
): Promise<T | undefined> {
let { result, cancel: cancelResult } = retryUntilCancel(20, callback);
const { result, cancel: cancelResult } = retryUntilCancel(20, callback);

let timer: NodeJS.Timeout | undefined;
let timeoutPromise = new Promise<undefined>((resolve) => {
timer = setTimeout(() => resolve, timeoutMs);
const timeoutPromise = new Promise<undefined>((resolve) => {
timer = setTimeout(() => resolve(undefined), timeoutMs);
});

try {
Expand All @@ -19,6 +28,16 @@ export async function retryUntilTimeout<T>(
}
}

/**
* Repeatedly calls callback until it executes without throwing an error, or
* until the operation is cancelled.
* @param intervalMs Number of milliseconds to wait between retries.
* @param callback The function to call repeatedly.
* @returns An object with two properties: `result`, a promise that resolves to
* the return value of the first successful call to the callback (doesn't have
* to be truthy, just not erroring) or rejects with Error("Cancelled") if
* cancelled; and `cancel`, a function you can call to stop the retries.
*/
export function retryUntilCancel<T>(
intervalMs: number,
callback: () => Promise<T>
Expand Down
Loading

0 comments on commit 6bdac65

Please sign in to comment.