diff --git a/config/scripts/fixtures/node-pty-1.1.0/LICENSE b/config/scripts/fixtures/node-pty-1.1.0/LICENSE deleted file mode 100644 index 22f780daf96..00000000000 --- a/config/scripts/fixtures/node-pty-1.1.0/LICENSE +++ /dev/null @@ -1,69 +0,0 @@ -Copyright (c) 2012-2015, Christopher Jeffrey (https://github.com/chjj/) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - - -The MIT License (MIT) - -Copyright (c) 2016, Daniel Imms (http://www.growingwiththeweb.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - -MIT License - -Copyright (c) 2018 - present Microsoft Corporation - -All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/config/scripts/fixtures/node-pty-1.1.0/README.md b/config/scripts/fixtures/node-pty-1.1.0/README.md deleted file mode 100644 index 8d6428b9f81..00000000000 --- a/config/scripts/fixtures/node-pty-1.1.0/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Legacy SSH teardown patch fixtures - -`windowsPtyAgent.js.txt` and `windowsTerminal.js.txt` are unmodified `lib/` files -from the published npm `node-pty@1.1.0` tarball. The upstream MIT license is in -`LICENSE`. These are test inputs only, not a shipped runtime dependency. - -The legacy SSH repair asset verifies both original and patched SHA-256 hashes. -Keeping its published input here lets clean installs test that compatibility -path after the desktop dependency is retired. diff --git a/config/scripts/fixtures/node-pty-1.1.0/windowsPtyAgent.js.txt b/config/scripts/fixtures/node-pty-1.1.0/windowsPtyAgent.js.txt deleted file mode 100644 index a358ffb1773..00000000000 --- a/config/scripts/fixtures/node-pty-1.1.0/windowsPtyAgent.js.txt +++ /dev/null @@ -1,320 +0,0 @@ -"use strict"; -/** - * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) - * Copyright (c) 2016, Daniel Imms (MIT License). - * Copyright (c) 2018, Microsoft Corporation (MIT License). - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.argsToCommandLine = exports.WindowsPtyAgent = void 0; -var fs = require("fs"); -var os = require("os"); -var path = require("path"); -var child_process_1 = require("child_process"); -var net_1 = require("net"); -var windowsConoutConnection_1 = require("./windowsConoutConnection"); -var utils_1 = require("./utils"); -var conptyNative; -var winptyNative; -/** - * The amount of time to wait for additional data after the conpty shell process has exited before - * shutting down the socket. The timer will be reset if a new data event comes in after the timer - * has started. - */ -var FLUSH_DATA_INTERVAL = 1000; -/** - * This agent sits between the WindowsTerminal class and provides a common interface for both conpty - * and winpty. - */ -var WindowsPtyAgent = /** @class */ (function () { - function WindowsPtyAgent(file, args, env, cwd, cols, rows, debug, _useConpty, _useConptyDll, conptyInheritCursor) { - var _this = this; - if (_useConptyDll === void 0) { _useConptyDll = false; } - if (conptyInheritCursor === void 0) { conptyInheritCursor = false; } - this._useConpty = _useConpty; - this._useConptyDll = _useConptyDll; - this._pid = 0; - this._innerPid = 0; - if (this._useConpty === undefined || this._useConpty === true) { - this._useConpty = this._getWindowsBuildNumber() >= 18309; - } - if (this._useConpty) { - if (!conptyNative) { - conptyNative = utils_1.loadNativeModule('conpty').module; - } - } - else { - if (!winptyNative) { - winptyNative = utils_1.loadNativeModule('pty').module; - } - } - this._ptyNative = this._useConpty ? conptyNative : winptyNative; - // Sanitize input variable. - cwd = path.resolve(cwd); - // Compose command line - var commandLine = argsToCommandLine(file, args); - // Open pty session. - var term; - if (this._useConpty) { - term = this._ptyNative.startProcess(file, cols, rows, debug, this._generatePipeName(), conptyInheritCursor, this._useConptyDll); - } - else { - term = this._ptyNative.startProcess(file, commandLine, env, cwd, cols, rows, debug); - this._pid = term.pid; - this._innerPid = term.innerPid; - } - // Not available on windows. - this._fd = term.fd; - // Generated incremental number that has no real purpose besides using it - // as a terminal id. - this._pty = term.pty; - // Create terminal pipe IPC channel and forward to a local unix socket. - this._outSocket = new net_1.Socket(); - this._outSocket.setEncoding('utf8'); - // The conout socket must be ready out on another thread to avoid deadlocks - this._conoutSocketWorker = new windowsConoutConnection_1.ConoutConnection(term.conout, this._useConptyDll); - this._conoutSocketWorker.onReady(function () { - _this._conoutSocketWorker.connectSocket(_this._outSocket); - }); - this._outSocket.on('connect', function () { - _this._outSocket.emit('ready_datapipe'); - }); - var inSocketFD = fs.openSync(term.conin, 'w'); - this._inSocket = new net_1.Socket({ - fd: inSocketFD, - readable: false, - writable: true - }); - this._inSocket.setEncoding('utf8'); - if (this._useConpty) { - var connect = this._ptyNative.connect(this._pty, commandLine, cwd, env, this._useConptyDll, function (c) { return _this._$onProcessExit(c); }); - this._innerPid = connect.pid; - } - } - Object.defineProperty(WindowsPtyAgent.prototype, "inSocket", { - get: function () { return this._inSocket; }, - enumerable: false, - configurable: true - }); - Object.defineProperty(WindowsPtyAgent.prototype, "outSocket", { - get: function () { return this._outSocket; }, - enumerable: false, - configurable: true - }); - Object.defineProperty(WindowsPtyAgent.prototype, "fd", { - get: function () { return this._fd; }, - enumerable: false, - configurable: true - }); - Object.defineProperty(WindowsPtyAgent.prototype, "innerPid", { - get: function () { return this._innerPid; }, - enumerable: false, - configurable: true - }); - Object.defineProperty(WindowsPtyAgent.prototype, "pty", { - get: function () { return this._pty; }, - enumerable: false, - configurable: true - }); - WindowsPtyAgent.prototype.resize = function (cols, rows) { - if (this._useConpty) { - if (this._exitCode !== undefined) { - throw new Error('Cannot resize a pty that has already exited'); - } - this._ptyNative.resize(this._pty, cols, rows, this._useConptyDll); - return; - } - this._ptyNative.resize(this._pid, cols, rows); - }; - WindowsPtyAgent.prototype.clear = function () { - if (this._useConpty) { - this._ptyNative.clear(this._pty, this._useConptyDll); - } - }; - WindowsPtyAgent.prototype.kill = function () { - var _this = this; - // Tell the agent to kill the pty, this releases handles to the process - if (this._useConpty) { - if (!this._useConptyDll) { - this._inSocket.readable = false; - this._outSocket.readable = false; - this._getConsoleProcessList().then(function (consoleProcessList) { - consoleProcessList.forEach(function (pid) { - try { - process.kill(pid); - } - catch (e) { - // Ignore if process cannot be found (kill ESRCH error) - } - }); - }); - this._ptyNative.kill(this._pty, this._useConptyDll); - this._conoutSocketWorker.dispose(); - } - else { - // Close the input write handle to signal the end of session. - this._inSocket.destroy(); - this._ptyNative.kill(this._pty, this._useConptyDll); - this._outSocket.on('data', function () { - _this._conoutSocketWorker.dispose(); - }); - } - } - else { - // Because pty.kill closes the handle, it will kill most processes by itself. - // Process IDs can be reused as soon as all handles to them are - // dropped, so we want to immediately kill the entire console process list. - // If we do not force kill all processes here, node servers in particular - // seem to become detached and remain running (see - // Microsoft/vscode#26807). - var processList = this._ptyNative.getProcessList(this._pid); - this._ptyNative.kill(this._pid, this._innerPid); - processList.forEach(function (pid) { - try { - process.kill(pid); - } - catch (e) { - // Ignore if process cannot be found (kill ESRCH error) - } - }); - } - }; - WindowsPtyAgent.prototype._getConsoleProcessList = function () { - var _this = this; - return new Promise(function (resolve) { - var agent = child_process_1.fork(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()]); - agent.on('message', function (message) { - clearTimeout(timeout); - resolve(message.consoleProcessList); - }); - var timeout = setTimeout(function () { - // Something went wrong, just send back the shell PID - agent.kill(); - resolve([_this._innerPid]); - }, 5000); - }); - }; - Object.defineProperty(WindowsPtyAgent.prototype, "exitCode", { - get: function () { - if (this._useConpty) { - return this._exitCode; - } - var winptyExitCode = this._ptyNative.getExitCode(this._innerPid); - return winptyExitCode === -1 ? undefined : winptyExitCode; - }, - enumerable: false, - configurable: true - }); - WindowsPtyAgent.prototype._getWindowsBuildNumber = function () { - var osVersion = (/(\d+)\.(\d+)\.(\d+)/g).exec(os.release()); - var buildNumber = 0; - if (osVersion && osVersion.length === 4) { - buildNumber = parseInt(osVersion[3]); - } - return buildNumber; - }; - WindowsPtyAgent.prototype._generatePipeName = function () { - return "conpty-" + Math.random() * 10000000; - }; - /** - * Triggered from the native side when a contpy process exits. - */ - WindowsPtyAgent.prototype._$onProcessExit = function (exitCode) { - var _this = this; - this._exitCode = exitCode; - if (!this._useConptyDll) { - this._flushDataAndCleanUp(); - this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); }); - } - }; - WindowsPtyAgent.prototype._flushDataAndCleanUp = function () { - var _this = this; - if (this._useConptyDll) { - return; - } - if (this._closeTimeout) { - clearTimeout(this._closeTimeout); - } - this._closeTimeout = setTimeout(function () { return _this._cleanUpProcess(); }, FLUSH_DATA_INTERVAL); - }; - WindowsPtyAgent.prototype._cleanUpProcess = function () { - if (this._useConptyDll) { - return; - } - this._inSocket.readable = false; - this._outSocket.readable = false; - this._outSocket.destroy(); - }; - return WindowsPtyAgent; -}()); -exports.WindowsPtyAgent = WindowsPtyAgent; -// Convert argc/argv into a Win32 command-line following the escaping convention -// documented on MSDN (e.g. see CommandLineToArgvW documentation). Copied from -// winpty project. -function argsToCommandLine(file, args) { - if (isCommandLine(args)) { - if (args.length === 0) { - return file; - } - return argsToCommandLine(file, []) + " " + args; - } - var argv = [file]; - Array.prototype.push.apply(argv, args); - var result = ''; - for (var argIndex = 0; argIndex < argv.length; argIndex++) { - if (argIndex > 0) { - result += ' '; - } - var arg = argv[argIndex]; - // if it is empty or it contains whitespace and is not already quoted - var hasLopsidedEnclosingQuote = xOr((arg[0] !== '"'), (arg[arg.length - 1] !== '"')); - var hasNoEnclosingQuotes = ((arg[0] !== '"') && (arg[arg.length - 1] !== '"')); - var quote = arg === '' || - (arg.indexOf(' ') !== -1 || - arg.indexOf('\t') !== -1) && - ((arg.length > 1) && - (hasLopsidedEnclosingQuote || hasNoEnclosingQuotes)); - if (quote) { - result += '\"'; - } - var bsCount = 0; - for (var i = 0; i < arg.length; i++) { - var p = arg[i]; - if (p === '\\') { - bsCount++; - } - else if (p === '"') { - result += repeatText('\\', bsCount * 2 + 1); - result += '"'; - bsCount = 0; - } - else { - result += repeatText('\\', bsCount); - bsCount = 0; - result += p; - } - } - if (quote) { - result += repeatText('\\', bsCount * 2); - result += '\"'; - } - else { - result += repeatText('\\', bsCount); - } - } - return result; -} -exports.argsToCommandLine = argsToCommandLine; -function isCommandLine(args) { - return typeof args === 'string'; -} -function repeatText(text, count) { - var result = ''; - for (var i = 0; i < count; i++) { - result += text; - } - return result; -} -function xOr(arg1, arg2) { - return ((arg1 && !arg2) || (!arg1 && arg2)); -} -//# sourceMappingURL=windowsPtyAgent.js.map \ No newline at end of file diff --git a/config/scripts/fixtures/node-pty-1.1.0/windowsTerminal.js.txt b/config/scripts/fixtures/node-pty-1.1.0/windowsTerminal.js.txt deleted file mode 100644 index 3c38f89d14c..00000000000 --- a/config/scripts/fixtures/node-pty-1.1.0/windowsTerminal.js.txt +++ /dev/null @@ -1,199 +0,0 @@ -"use strict"; -/** - * Copyright (c) 2012-2015, Christopher Jeffrey, Peter Sunde (MIT License) - * Copyright (c) 2016, Daniel Imms (MIT License). - * Copyright (c) 2018, Microsoft Corporation (MIT License). - */ -var __extends = (this && this.__extends) || (function () { - var extendStatics = function (d, b) { - extendStatics = Object.setPrototypeOf || - ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || - function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; - return extendStatics(d, b); - }; - return function (d, b) { - extendStatics(d, b); - function __() { this.constructor = d; } - d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); - }; -})(); -Object.defineProperty(exports, "__esModule", { value: true }); -exports.WindowsTerminal = void 0; -var terminal_1 = require("./terminal"); -var windowsPtyAgent_1 = require("./windowsPtyAgent"); -var utils_1 = require("./utils"); -var DEFAULT_FILE = 'cmd.exe'; -var DEFAULT_NAME = 'Windows Shell'; -var WindowsTerminal = /** @class */ (function (_super) { - __extends(WindowsTerminal, _super); - function WindowsTerminal(file, args, opt) { - var _this = _super.call(this, opt) || this; - _this._checkType('args', args, 'string', true); - // Initialize arguments - args = args || []; - file = file || DEFAULT_FILE; - opt = opt || {}; - opt.env = opt.env || process.env; - if (opt.encoding) { - console.warn('Setting encoding on Windows is not supported'); - } - var env = utils_1.assign({}, opt.env); - _this._cols = opt.cols || terminal_1.DEFAULT_COLS; - _this._rows = opt.rows || terminal_1.DEFAULT_ROWS; - var cwd = opt.cwd || process.cwd(); - var name = opt.name || env.TERM || DEFAULT_NAME; - var parsedEnv = _this._parseEnv(env); - // If the terminal is ready - _this._isReady = false; - // Functions that need to run after `ready` event is emitted. - _this._deferreds = []; - // Create new termal. - _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor); - _this._socket = _this._agent.outSocket; - // Not available until `ready` event emitted. - _this._pid = _this._agent.innerPid; - _this._fd = _this._agent.fd; - _this._pty = _this._agent.pty; - // The forked windows terminal is not available until `ready` event is - // emitted. - _this._socket.on('ready_datapipe', function () { - // Run deferreds and set ready state once the first data event is received. - _this._socket.once('data', function () { - // Wait until the first data event is fired then we can run deferreds. - if (!_this._isReady) { - // Terminal is now ready and we can avoid having to defer method - // calls. - _this._isReady = true; - // Execute all deferred methods - _this._deferreds.forEach(function (fn) { - // NB! In order to ensure that `this` has all its references - // updated any variable that need to be available in `this` before - // the deferred is run has to be declared above this forEach - // statement. - fn.run(); - }); - // Reset - _this._deferreds = []; - } - }); - // Shutdown if `error` event is emitted. - _this._socket.on('error', function (err) { - // Close terminal session. - _this._close(); - // EIO, happens when someone closes our child process: the only process - // in the terminal. - // node < 0.6.14: errno 5 - // node >= 0.6.14: read EIO - if (err.code) { - if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO')) - return; - } - // Throw anything else. - if (_this.listeners('error').length < 2) { - throw err; - } - }); - // Cleanup after the socket is closed. - _this._socket.on('close', function () { - _this.emit('exit', _this._agent.exitCode); - _this._close(); - }); - }); - _this._file = file; - _this._name = name; - _this._readable = true; - _this._writable = true; - _this._forwardEvents(); - return _this; - } - WindowsTerminal.prototype._write = function (data) { - this._defer(this._doWrite, data); - }; - WindowsTerminal.prototype._doWrite = function (data) { - this._agent.inSocket.write(data); - }; - /** - * openpty - */ - WindowsTerminal.open = function (options) { - throw new Error('open() not supported on windows, use Fork() instead.'); - }; - /** - * TTY - */ - WindowsTerminal.prototype.resize = function (cols, rows) { - var _this = this; - if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) { - throw new Error('resizing must be done using positive cols and rows'); - } - this._deferNoArgs(function () { - _this._agent.resize(cols, rows); - _this._cols = cols; - _this._rows = rows; - }); - }; - WindowsTerminal.prototype.clear = function () { - var _this = this; - this._deferNoArgs(function () { - _this._agent.clear(); - }); - }; - WindowsTerminal.prototype.destroy = function () { - var _this = this; - this._deferNoArgs(function () { - _this.kill(); - }); - }; - WindowsTerminal.prototype.kill = function (signal) { - var _this = this; - this._deferNoArgs(function () { - if (signal) { - throw new Error('Signals not supported on windows.'); - } - _this._close(); - _this._agent.kill(); - }); - }; - WindowsTerminal.prototype._deferNoArgs = function (deferredFn) { - var _this = this; - // If the terminal is ready, execute. - if (this._isReady) { - deferredFn.call(this); - return; - } - // Queue until terminal is ready. - this._deferreds.push({ - run: function () { return deferredFn.call(_this); } - }); - }; - WindowsTerminal.prototype._defer = function (deferredFn, arg) { - var _this = this; - // If the terminal is ready, execute. - if (this._isReady) { - deferredFn.call(this, arg); - return; - } - // Queue until terminal is ready. - this._deferreds.push({ - run: function () { return deferredFn.call(_this, arg); } - }); - }; - Object.defineProperty(WindowsTerminal.prototype, "process", { - get: function () { return this._name; }, - enumerable: false, - configurable: true - }); - Object.defineProperty(WindowsTerminal.prototype, "master", { - get: function () { throw new Error('master is not supported on Windows'); }, - enumerable: false, - configurable: true - }); - Object.defineProperty(WindowsTerminal.prototype, "slave", { - get: function () { throw new Error('slave is not supported on Windows'); }, - enumerable: false, - configurable: true - }); - return WindowsTerminal; -}(terminal_1.Terminal)); -exports.WindowsTerminal = WindowsTerminal; -//# sourceMappingURL=windowsTerminal.js.map \ No newline at end of file diff --git a/src/main/ssh/ssh-relay-native-deps-cache-commands.ts b/src/main/ssh/ssh-relay-native-deps-cache-commands.ts index f2ce32a2222..78feb621d99 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-commands.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-commands.ts @@ -1,160 +1,19 @@ -/** - * The remote shell for the shared native-deps cache (`ssh-relay-native-deps-cache.ts`). - * - * Every script here is POSIX `sh` and answers with one token, because the only alternative to a - * token is inferring success from an exit status the transport can also produce. A command that - * cannot answer is a cache miss, never a licence to delete: `MISS` and `NOT_PROMOTED` both leave - * the relay directory owning its own `node_modules`, which is exactly today's behaviour. - */ +/** Historical cache scans require complete reference evidence before removal. */ import { shellEscape } from './ssh-connection-utils' import { RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME, RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, relayNativeDepsCacheBaseDir, - relayNativeDepsCacheEntryDir, - relayNativeDepsCacheNodeModulesPath, remoteInstallRootDir } from './ssh-relay-native-deps-cache' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import type { RemoteHostPlatform } from './ssh-remote-platform' -export const RELAY_NATIVE_CACHE_LINKED = '__ORCA_NATIVE_CACHE__LINKED' -export const RELAY_NATIVE_CACHE_SEEDED = '__ORCA_NATIVE_CACHE__SEEDED' -export const RELAY_NATIVE_CACHE_MISS = '__ORCA_NATIVE_CACHE__MISS' -export const RELAY_NATIVE_CACHE_PROMOTED = '__ORCA_NATIVE_CACHE__PROMOTED' -export const RELAY_NATIVE_CACHE_NOT_PROMOTED = '__ORCA_NATIVE_CACHE__NOT_PROMOTED' export const RELAY_NATIVE_CACHE_LIST_OK = '__ORCA_NATIVE_CACHE__LIST_OK' export const RELAY_NATIVE_CACHE_REFS_OK = '__ORCA_NATIVE_CACHE__REFS_OK' export const RELAY_NATIVE_CACHE_REFS_ERR = '__ORCA_NATIVE_CACHE__REFS_ERR' - -/** - * How old an entry without `.deps-complete` must be before another deploy may reclaim it. Well - * past the 15-minute deploy ceiling, so a live installer is never mistaken for a crashed one. - * Reclaiming is safe at any age in principle — nothing links an entry until it is complete — but - * the margin is what keeps that argument from resting on a single `[ -f ]`. - */ -const CACHE_TAKEOVER_MINUTES = 120 - -/** Bounds every listing, matching `MAX_RELAY_GC_LISTING_ENTRIES`' role for version dirs. */ export const MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES = 64 -export type RelayNativeDepsCachePaths = { - host: RemoteHostPlatform - remoteHome: string - relayDir: string - key: string -} - -function cachePaths(paths: RelayNativeDepsCachePaths): { - base: string - entry: string - target: string - nodeModules: string - root: string -} { - const { host, remoteHome, relayDir, key } = paths - return { - base: relayNativeDepsCacheBaseDir(host, remoteHome), - entry: relayNativeDepsCacheEntryDir(host, remoteHome, key), - target: relayNativeDepsCacheNodeModulesPath(host, remoteHome, key), - nodeModules: joinRemotePath(host, relayDir, 'node_modules'), - root: remoteInstallRootDir(host, remoteHome) - } -} - -/** - * Link a complete entry into the relay directory, or seed a private tree from a sibling relay - * directory that already has a matching one. - * - * The seed exists so the first deploy after this ships does not recompile once more on a host - * that already paid for the compile. It is not trusted: the copy is a plain private install until - * the normal probe loads both addons, and only then is it promoted. - */ -export function ensureRelayNativeDepsCacheCommand( - paths: RelayNativeDepsCachePaths, - deps: Readonly> -): string { - const { entry, target, nodeModules, root } = cachePaths(paths) - // Why grep the sibling's manifest: an older Orca pinned different versions, and a - // toolchain-skip host wrote one with node-pty removed. Both must fail to qualify. - const depGuards = Object.entries(deps).map( - ([name, version]) => `grep -F -q ${shellEscape(`"${name}":"${version}"`)} "$pj" || continue` - ) - return [ - `cache=${shellEscape(entry)}`, - `target=${shellEscape(target)}`, - `nm=${shellEscape(nodeModules)}`, - `root=${shellEscape(root)}`, - `if [ -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ] && [ -d "$target" ]; then`, - ' if [ -L "$nm" ]; then', - ` if [ "$(readlink "$nm" 2>/dev/null)" = "$target" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_LINKED}; exit 0; fi`, - ' rm -f "$nm" 2>/dev/null || true', - ' fi', - ` if [ ! -e "$nm" ] && ln -s "$target" "$nm" 2>/dev/null; then printf '%s\\n' ${RELAY_NATIVE_CACHE_LINKED}; exit 0; fi`, - ` printf '%s\\n' ${RELAY_NATIVE_CACHE_MISS}; exit 0`, - 'fi', - 'if [ ! -e "$nm" ] && [ ! -L "$nm" ]; then', - ' for cand in "$root"/relay-*/node_modules; do', - ' [ -d "$cand" ] || continue', - ' [ -L "$cand" ] && continue', - ' [ -d "$cand/node-pty" ] || continue', - ' [ -d "$cand/@parcel/watcher" ] || continue', - ' pj="${cand%/node_modules}/package.json"', - ' [ -f "$pj" ] || continue', - ...depGuards.map((guard) => ` ${guard}`), - ' seed="$nm.seed.$$"', - ' rm -rf "$seed" 2>/dev/null || true', - ' if cp -Rp "$cand" "$seed" 2>/dev/null && mv "$seed" "$nm" 2>/dev/null; then', - ` printf '%s\\n' ${RELAY_NATIVE_CACHE_SEEDED}; exit 0`, - ' fi', - ' rm -rf "$seed" 2>/dev/null || true', - ' break', - ' done', - 'fi', - `printf '%s\\n' ${RELAY_NATIVE_CACHE_MISS}` - ].join('\n') -} - -/** - * Publish a probe-verified private tree as the shared entry, then link the relay directory to it. - * - * `mkdir "$cache"` is the election: exactly one deploy creates the directory, and a loser keeps - * its own tree rather than writing into someone else's. `.deps-complete` is written last, after - * the symlink exists, so an entry is never linkable before it is referenced. - */ -export function promoteRelayNativeDepsCacheCommand(paths: RelayNativeDepsCachePaths): string { - const { base, entry, target, nodeModules } = cachePaths(paths) - const notPromoted = `printf '%s\\n' ${RELAY_NATIVE_CACHE_NOT_PROMOTED}` - return [ - `base=${shellEscape(base)}`, - `cache=${shellEscape(entry)}`, - `target=${shellEscape(target)}`, - `nm=${shellEscape(nodeModules)}`, - `[ -d "$nm" ] || { ${notPromoted}; exit 0; }`, - `if [ -L "$nm" ]; then ${notPromoted}; exit 0; fi`, - `mkdir -p "$base" 2>/dev/null || { ${notPromoted}; exit 0; }`, - `if [ -d "$cache" ] && [ ! -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ]; then`, - ` if [ -n "$(find "$cache" -maxdepth 0 -mmin +${CACHE_TAKEOVER_MINUTES} 2>/dev/null)" ]; then`, - ' rm -rf "$cache" 2>/dev/null || true', - ' fi', - 'fi', - `mkdir "$cache" 2>/dev/null || { ${notPromoted}; exit 0; }`, - 'if mv "$nm" "$target" 2>/dev/null; then', - ' if ln -s "$target" "$nm" 2>/dev/null; then', - ` : > "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" 2>/dev/null || true`, - ` if [ -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_PROMOTED}; exit 0; fi`, - ' fi', - ' rm -f "$nm" 2>/dev/null || true', - // Why the rm is conditional on the move back: a failed restore leaves the only copy of the - // tree inside an incomplete entry. Deleting it there would cost the relay its native deps. - ' if mv "$target" "$nm" 2>/dev/null; then rm -rf "$cache" 2>/dev/null || true; fi', - ` ${notPromoted}; exit 0`, - 'fi', - 'rm -rf "$cache" 2>/dev/null || true', - notPromoted - ].join('\n') -} - /** Complete entries and tombstones; deletion requires a separate reference scan. */ export function listRelayNativeDepsCacheEntriesCommand( host: RemoteHostPlatform, diff --git a/src/main/ssh/ssh-relay-native-deps-cache-install.ts b/src/main/ssh/ssh-relay-native-deps-cache-install.ts deleted file mode 100644 index adc1f01d54f..00000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache-install.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * The deploy-side half of the shared native-deps cache: resolve this build's key, try to link an - * existing entry, and publish a probe-verified tree afterwards. - * - * Both entry points answer with a value, never an exception. The cache is an optimization on a - * path that must still connect a host with a read-only home, no `ln`, or an SSH server that drops - * the channel — every one of those is a plain per-directory install, which is what the relay did - * before this existed. - */ -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import type { SshConnection } from './ssh-connection' -import { RELAY_ARTIFACTS } from '../../shared/relay-artifacts' -import { execCommand } from './ssh-relay-deploy-helpers' -import { NATIVE_DEPS_COMMAND_TIMEOUT_MS } from './ssh-relay-deploy-timing' -import { - computeRelayNativeDepsCacheKey, - supportsRelayNativeDepsCache, - RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN, - type RelayNativeDepsCachePatchSource -} from './ssh-relay-native-deps-cache' -import { - ensureRelayNativeDepsCacheCommand, - promoteRelayNativeDepsCacheCommand, - RELAY_NATIVE_CACHE_LINKED, - RELAY_NATIVE_CACHE_PROMOTED, - RELAY_NATIVE_CACHE_SEEDED, - type RelayNativeDepsCachePaths -} from './ssh-relay-native-deps-cache-commands' -import type { RemoteHostPlatform } from './ssh-remote-platform' - -/** - * `linked` — the relay directory now points at a complete shared entry and needs no install. - * `private` — it owns (or is about to own) its own tree, which promotion may later publish. - */ -export type RelayNativeDepsCacheAttachment = { - mode: 'linked' | 'private' - key: string -} - -export type RelayNativeDepsCacheContext = { - hostPlatform: RemoteHostPlatform - remoteHome: string - relayDir: string - platform: string - localRelayDir: string - deps: Readonly> - signal?: AbortSignal -} - -function execHostCommand( - conn: SshConnection, - host: RemoteHostPlatform, - command: string, - signal?: AbortSignal -): Promise { - return execCommand(conn, command, { - wrapCommand: host.commandDialect !== 'powershell', - // Why the native-deps budget and not the default 30s: a seeding copy moves a whole - // node_modules on the host's own disk, which is fast but not instant on a cold cache. - timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, - signal - }) -} - -/** - * Every shipped artifact that patches the installed native tree, read for hashing. - * - * Reading is best-effort by design: a patch this client cannot read must not silently drop out of - * the key, so an unreadable one disables the cache rather than producing a key that claims the - * patch was applied. - */ -export function readRelayNativeDepsPatchSources( - localRelayDir: string -): RelayNativeDepsCachePatchSource[] | null { - const sources: RelayNativeDepsCachePatchSource[] = [] - for (const artifact of RELAY_ARTIFACTS) { - if (!RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN.test(artifact.filename)) { - continue - } - const path = join(localRelayDir, artifact.filename) - try { - if (!existsSync(path)) { - continue - } - sources.push({ filename: artifact.filename, contents: readFileSync(path, 'utf-8') }) - } catch { - return null - } - } - return sources -} - -/** This build's cache key, or null when it cannot be computed and the cache must stay off. */ -export function resolveRelayNativeDepsCacheKey(context: { - platform: string - localRelayDir: string - deps: Readonly> -}): string | null { - const patchSources = readRelayNativeDepsPatchSources(context.localRelayDir) - if (!patchSources) { - return null - } - try { - return computeRelayNativeDepsCacheKey({ - platform: context.platform, - deps: context.deps, - patchSources - }) - } catch (err) { - console.warn( - `[ssh-relay] Native-deps cache key unavailable for ${context.platform}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return null - } -} - -/** - * Link a complete entry, or leave the relay directory to install privately. - * - * Returns null when the cache is off for this host, which keeps the caller on the exact command - * sequence it ran before the cache existed. - */ -export async function attachRelayNativeDepsCache( - conn: SshConnection, - context: RelayNativeDepsCacheContext -): Promise { - if (!supportsRelayNativeDepsCache(context.hostPlatform)) { - return null - } - const key = resolveRelayNativeDepsCacheKey(context) - if (!key) { - return null - } - const paths = cachePathsFor(context, key) - try { - const output = await execHostCommand( - conn, - context.hostPlatform, - ensureRelayNativeDepsCacheCommand(paths, context.deps), - context.signal - ) - if (output.includes(RELAY_NATIVE_CACHE_LINKED)) { - console.log(`[ssh-relay] Native deps linked from shared cache entry ${key}`) - return { mode: 'linked', key } - } - if (output.includes(RELAY_NATIVE_CACHE_SEEDED)) { - console.log(`[ssh-relay] Seeded native deps for ${key} from an existing install on this host`) - } - return { mode: 'private', key } - } catch (err) { - context.signal?.throwIfAborted() - // Why still 'private' and not null: the relay directory owns nothing yet either way, and the - // install that follows is identical. Promotion afterwards is separately best-effort. - console.warn( - `[ssh-relay] Native-deps cache probe for ${key} failed; installing per-directory: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return { mode: 'private', key } - } -} - -/** - * Publish a private tree the probe just loaded, and link the relay directory to it. - * - * Called only after `probeInstalledNativeDeps` reported both addons loadable on this host, so an - * entry is never published on the strength of a successful `npm install` alone. - */ -export async function promoteRelayNativeDepsCache( - conn: SshConnection, - context: RelayNativeDepsCacheContext, - key: string -): Promise { - try { - const output = await execHostCommand( - conn, - context.hostPlatform, - promoteRelayNativeDepsCacheCommand(cachePathsFor(context, key)), - context.signal - ) - console.log( - output.includes(RELAY_NATIVE_CACHE_PROMOTED) - ? `[ssh-relay] Published native deps as shared cache entry ${key}` - : `[ssh-relay] Native deps stay per-directory; shared cache entry ${key} was not published` - ) - } catch (err) { - context.signal?.throwIfAborted() - console.warn( - `[ssh-relay] Could not publish native-deps cache entry ${key}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - } -} - -function cachePathsFor( - context: RelayNativeDepsCacheContext, - key: string -): RelayNativeDepsCachePaths { - return { - host: context.hostPlatform, - remoteHome: context.remoteHome, - relayDir: context.relayDir, - key - } -} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts index 530eed7238e..fff61a030f2 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts @@ -1,90 +1,42 @@ -// The cache's safety argument is made of `sh`, not TypeScript: `mkdir` elects the publisher, -// `.deps-complete` gates linking, and a failed publish must put the tree back. Asserting on the -// command strings cannot show any of that, so these run the real scripts against a real tree. - import { execFileSync } from 'node:child_process' -import { - mkdirSync, - mkdtempSync, - readlinkSync, - rmSync, - statSync, - symlinkSync, - writeFileSync, - existsSync, - lstatSync -} from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' - import { getRemoteHostPlatform } from './ssh-remote-platform' import { - computeRelayNativeDepsCacheKey, relayNativeDepsCacheEntryDir, relayNativeDepsCacheNodeModulesPath } from './ssh-relay-native-deps-cache' import { - ensureRelayNativeDepsCacheCommand, listRelayNativeDepsCacheEntriesCommand, listRelayNativeDepsCacheReferencesCommand, - promoteRelayNativeDepsCacheCommand, - RELAY_NATIVE_CACHE_LINKED, RELAY_NATIVE_CACHE_LIST_OK, - RELAY_NATIVE_CACHE_MISS, - RELAY_NATIVE_CACHE_NOT_PROMOTED, - RELAY_NATIVE_CACHE_PROMOTED, - RELAY_NATIVE_CACHE_REFS_OK, - RELAY_NATIVE_CACHE_SEEDED + RELAY_NATIVE_CACHE_REFS_OK } from './ssh-relay-native-deps-cache-commands' const HOST = getRemoteHostPlatform('linux-x64') -const DEPS = { 'node-pty': '1.1.0', '@parcel/watcher': '2.5.6' } as const -const KEY = computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS }) +const KEY = 'linux-x64-99355f00e9e557a3' -// Debian and Ubuntu point /bin/sh at dash, which is stricter than the bash-in-sh-mode that macOS -// ships; run against both when both exist so a bashism cannot pass here and fail on a host. const SHELLS = ['/bin/sh', '/bin/dash'].filter((shell) => existsSync(shell)) describe.runIf(process.platform !== 'win32').each(SHELLS)( - 'relay native-deps cache shell scripts (%s)', + 'historical relay native cache scans (%s)', (shell) => { let home: string - const relayDir = (version: string): string => join(home, '.orca-remote', `relay-${version}`) function sh(command: string): string { return execFileSync(shell, ['-c', command], { encoding: 'utf-8' }) } - /** A relay directory holding its own installed tree, exactly as `npm install` leaves it. */ - function makePrivateInstall(version: string, deps: Record = DEPS): string { - const dir = relayDir(version) - mkdirSync(join(dir, 'node_modules', 'node-pty', 'build'), { recursive: true }) - mkdirSync(join(dir, 'node_modules', '@parcel', 'watcher'), { recursive: true }) - writeFileSync(join(dir, 'node_modules', 'node-pty', 'build', 'pty.node'), 'binary') - writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: deps })) - return dir - } - - function ensure(version: string): string { - return sh( - ensureRelayNativeDepsCacheCommand( - { host: HOST, remoteHome: home, relayDir: relayDir(version), key: KEY }, - DEPS - ) - ).trim() - } - - function promote(version: string): string { - return sh( - promoteRelayNativeDepsCacheCommand({ - host: HOST, - remoteHome: home, - relayDir: relayDir(version), - key: KEY - }) - ).trim() + function makeLegacyCache(version: string): void { + const entry = relayNativeDepsCacheEntryDir(HOST, home, KEY) + const target = relayNativeDepsCacheNodeModulesPath(HOST, home, KEY) + mkdirSync(target, { recursive: true }) + writeFileSync(join(entry, '.deps-complete'), '') + mkdirSync(relayDir(version), { recursive: true }) + symlinkSync(target, join(relayDir(version), 'node_modules')) } beforeEach(() => { @@ -96,119 +48,8 @@ describe.runIf(process.platform !== 'win32').each(SHELLS)( rmSync(home, { recursive: true, force: true }) }) - it('publishes a probe-verified tree and links the relay directory to it', () => { - makePrivateInstall('0.1.0+aaa') - - expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_PROMOTED) - - const target = relayNativeDepsCacheNodeModulesPath(HOST, home, KEY) - expect(readlinkSync(join(relayDir('0.1.0+aaa'), 'node_modules'))).toBe(target) - expect(statSync(join(target, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) - expect( - existsSync(join(relayNativeDepsCacheEntryDir(HOST, home, KEY), '.deps-complete')) - ).toBe(true) - }) - - it('links a second bundle to the published tree with no install of its own', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - - // A different bundle: fresh directory, no node_modules, nothing installed. - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_LINKED) - - const linked = join(relayDir('0.1.0+bbb'), 'node_modules') - expect(readlinkSync(linked)).toBe(relayNativeDepsCacheNodeModulesPath(HOST, home, KEY)) - expect(statSync(join(linked, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) - }) - - it('will not link an entry whose completion sentinel is absent', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - rmSync(join(relayNativeDepsCacheEntryDir(HOST, home, KEY), '.deps-complete')) - - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) - expect(existsSync(join(relayDir('0.1.0+bbb'), 'node_modules'))).toBe(false) - }) - - it('seeds from a sibling install rather than recompiling once more', () => { - makePrivateInstall('0.1.0+aaa') - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_SEEDED) - - const seeded = join(relayDir('0.1.0+bbb'), 'node_modules') - expect(lstatSync(seeded).isSymbolicLink()).toBe(false) - expect(statSync(join(seeded, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) - // The source is untouched, so a relay running out of it is unaffected. - expect(existsSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty'))).toBe(true) - }) - - it('refuses to seed from a sibling pinned to different versions', () => { - makePrivateInstall('0.1.0+aaa', { 'node-pty': '1.0.0', '@parcel/watcher': '2.5.6' }) - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) - expect(existsSync(join(relayDir('0.1.0+bbb'), 'node_modules'))).toBe(false) - }) - - it('refuses to seed from a sibling that had node-pty skipped', () => { - makePrivateInstall('0.1.0+aaa') - rmSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty'), { recursive: true }) - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) - }) - - it('leaves a directory that installed for itself alone', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - const own = makePrivateInstall('0.1.0+bbb') - - expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) - expect(lstatSync(join(own, 'node_modules')).isSymbolicLink()).toBe(false) - }) - - it('elects exactly one publisher and leaves the loser its own tree', () => { - makePrivateInstall('0.1.0+aaa') - makePrivateInstall('0.1.0+bbb') - - expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_PROMOTED) - expect(promote('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_NOT_PROMOTED) - - // The loser must not have handed its tree to an entry it lost the race for. - const loser = join(relayDir('0.1.0+bbb'), 'node_modules') - expect(lstatSync(loser).isSymbolicLink()).toBe(false) - expect(statSync(join(loser, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) - }) - - it('never republishes through a symlink it already holds', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - - expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_NOT_PROMOTED) - expect(statSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty')).isDirectory()).toBe( - true - ) - }) - - it('survives removing a linked relay directory without touching the shared tree', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') - mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) - ensure('0.1.0+bbb') - - // Exactly what version GC does to an idle directory. - sh(`rm -rf ${JSON.stringify(relayDir('0.1.0+bbb'))}`) - - const target = relayNativeDepsCacheNodeModulesPath(HOST, home, KEY) - expect(statSync(join(target, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) - }) - it('reports the published entry and every symlink that references it', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') + makeLegacyCache('0.1.0+aaa') const entries = sh(listRelayNativeDepsCacheEntriesCommand(HOST, home)).trim().split('\n') expect(entries).toEqual([`ENTRY ${KEY}`, RELAY_NATIVE_CACHE_LIST_OK]) @@ -221,8 +62,7 @@ describe.runIf(process.platform !== 'win32').each(SHELLS)( }) it('reports a symlink no Orca version wrote, so GC can refuse the pass', () => { - makePrivateInstall('0.1.0+aaa') - promote('0.1.0+aaa') + makeLegacyCache('0.1.0+aaa') mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) symlinkSync('../relay-0.1.0+aaa/node_modules', join(relayDir('0.1.0+bbb'), 'node_modules')) diff --git a/src/main/ssh/ssh-relay-native-deps-cache.test.ts b/src/main/ssh/ssh-relay-native-deps-cache.test.ts index 7fee587b3ec..5ab4b7acf16 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache.test.ts @@ -1,7 +1,3 @@ -// The cache is shared across every relay directory on a host, so these cover the two things that -// make sharing safe: the key changes when the tree's inputs change, and GC refuses to delete on -// anything short of a complete, attributable reference listing. - import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./ssh-relay-deploy-helpers', () => ({ @@ -12,15 +8,12 @@ import { execCommand } from './ssh-relay-deploy-helpers' import type { SshConnection } from './ssh-connection' import { getRemoteHostPlatform } from './ssh-remote-platform' import { - computeRelayNativeDepsCacheKey, isRelayNativeDepsCacheEntryName, relayNativeDepsCacheEntryDir, relayNativeDepsCacheNodeModulesPath, supportsRelayNativeDepsCache } from './ssh-relay-native-deps-cache' import { - ensureRelayNativeDepsCacheCommand, - promoteRelayNativeDepsCacheCommand, RELAY_NATIVE_CACHE_LIST_OK, RELAY_NATIVE_CACHE_REFS_ERR, RELAY_NATIVE_CACHE_REFS_OK @@ -30,9 +23,7 @@ import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' const POSIX = getRemoteHostPlatform('linux-x64') const WINDOWS = getRemoteHostPlatform('win32-x64') const HOME = '/home/u' -const DEPS = { 'node-pty': '1.1.0', '@parcel/watcher': '2.5.6' } as const -const KEY = computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS }) -const RELAY_DIR = `${HOME}/.orca-remote/relay-0.1.0+aaa` +const KEY = 'linux-x64-99355f00e9e557a3' const conn = {} as SshConnection const mockExec = vi.mocked(execCommand) @@ -45,112 +36,21 @@ function listing(...keys: string[]): string { return [...keys.map((k) => `ENTRY ${k}`), RELAY_NATIVE_CACHE_LIST_OK].join('\n') } -describe('computeRelayNativeDepsCacheKey', () => { - it('keys on the dependency set, not on the relay bundle', () => { - expect(computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS })).toBe(KEY) - expect( - computeRelayNativeDepsCacheKey({ - platform: 'linux-x64', - deps: { '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' } - }) - ).toBe(KEY) - }) - - it('mints a new entry when a dependency version moves', () => { - expect( - computeRelayNativeDepsCacheKey({ - platform: 'linux-x64', - deps: { ...DEPS, 'node-pty': '1.2.0' } - }) - ).not.toBe(KEY) - }) - - it('mints a new entry when a patch applied to the tree changes', () => { - const withPatch = computeRelayNativeDepsCacheKey({ - platform: 'linux-x64', - deps: DEPS, - patchSources: [{ filename: 'node-pty-1.1.0-patch.cjs', contents: 'a' }] - }) - const withChangedPatch = computeRelayNativeDepsCacheKey({ - platform: 'linux-x64', - deps: DEPS, - patchSources: [{ filename: 'node-pty-1.1.0-patch.cjs', contents: 'b' }] - }) - expect(withPatch).not.toBe(KEY) - expect(withChangedPatch).not.toBe(withPatch) - }) - - it('separates platforms so one host never links another architecture', () => { - expect(computeRelayNativeDepsCacheKey({ platform: 'linux-arm64', deps: DEPS })).not.toBe(KEY) - expect(KEY.startsWith('linux-x64-')).toBe(true) - }) - - it('refuses a platform it cannot recognise rather than building a path from it', () => { - expect(() => computeRelayNativeDepsCacheKey({ platform: '../../etc', deps: DEPS })).toThrow( +describe('historical native cache identities', () => { + it('recognizes old keys and rejects unsafe directory names', () => { + expect(isRelayNativeDepsCacheEntryName(KEY)).toBe(true) + expect(isRelayNativeDepsCacheEntryName('../../etc')).toBe(false) + expect(() => relayNativeDepsCacheEntryDir(POSIX, HOME, '../../etc')).toThrow( /Unsafe relay native-deps cache key/ ) - expect(isRelayNativeDepsCacheEntryName('../../etc')).toBe(false) - expect(isRelayNativeDepsCacheEntryName(KEY)).toBe(true) }) - it('leaves Windows on the per-directory install', () => { + it('preserves the POSIX-only cache boundary', () => { expect(supportsRelayNativeDepsCache(POSIX)).toBe(true) expect(supportsRelayNativeDepsCache(WINDOWS)).toBe(false) }) }) -describe('ensureRelayNativeDepsCacheCommand', () => { - const command = ensureRelayNativeDepsCacheCommand( - { host: POSIX, remoteHome: HOME, relayDir: RELAY_DIR, key: KEY }, - DEPS - ) - - it('links only an entry that carries the completion sentinel', () => { - expect(command).toContain(`[ -f "$cache/.deps-complete" ]`) - expect(command).toContain('ln -s "$target" "$nm"') - expect(command).toContain(relayNativeDepsCacheNodeModulesPath(POSIX, HOME, KEY)) - }) - - it('never overwrites a directory the relay installed for itself', () => { - // The link is only created on a path that does not exist; a real node_modules reads as a miss. - expect(command).toContain('if [ ! -e "$nm" ] && ln -s "$target" "$nm"') - }) - - it('seeds only from a sibling whose manifest pins the same versions', () => { - for (const [name, version] of Object.entries(DEPS)) { - expect(command).toContain(`grep -F -q '"${name}":"${version}"' "$pj"`) - } - expect(command).toContain('[ -d "$cand/node-pty" ] || continue') - }) -}) - -describe('promoteRelayNativeDepsCacheCommand', () => { - const command = promoteRelayNativeDepsCacheCommand({ - host: POSIX, - remoteHome: HOME, - relayDir: RELAY_DIR, - key: KEY - }) - - it('elects one publisher with mkdir rather than a lock', () => { - expect(command).toContain('mkdir "$cache" 2>/dev/null') - }) - - it('writes the completion sentinel after the symlink exists', () => { - expect(command.indexOf('ln -s "$target" "$nm"')).toBeLessThan( - command.indexOf(': > "$cache/.deps-complete"') - ) - }) - - it('never deletes the entry unless the tree made it back to the relay directory', () => { - expect(command).toContain('if mv "$target" "$nm" 2>/dev/null; then rm -rf "$cache"') - }) - - it('refuses to publish a directory that is already a shared symlink', () => { - expect(command).toContain('if [ -L "$nm" ]; then') - }) -}) - describe('gcRelayNativeDepsCache', () => { beforeEach(() => { mockExec.mockReset().mockResolvedValue('') diff --git a/src/main/ssh/ssh-relay-native-deps-cache.ts b/src/main/ssh/ssh-relay-native-deps-cache.ts index 79f04221c01..069ed225a23 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache.ts @@ -1,36 +1,4 @@ -/** - * Where the relay's compiled native dependencies live, and what their identity is keyed on. - * - * A relay install directory is keyed on the JS bundle hash, which moves on every commit to - * `src/relay/` or the `src/shared/` it pulls in. `node_modules` used to live inside it, so a - * dependency set that is a pinned constant (`RELAY_NATIVE_DEPS`) was reinstalled — and on Linux, - * where node-pty ships no prebuild, recompiled from source — on every new bundle (#18009). The - * directory key was coupled to the wrong quantity. - * - * The tree now lives at `~/.orca-remote/native/-/node_modules` and each - * relay directory holds a symlink to it. Three rules make one tree safe to share: - * - * 1. **A published entry is immutable.** `.deps-complete` is written last, only after a probe on - * this host loaded both addons. Nothing installs, rebuilds or resets into a published entry: a - * repair detaches the symlink and installs privately, so a host with a broken toolchain can - * never `rm -rf node_modules/node-pty` out from under a live relay that shares the tree. - * 2. **Publication elects one winner with `mkdir`.** The entry either does not exist (this deploy - * builds it privately and promotes it) or is already complete (this deploy links it). There is - * no window in which two deploys write one tree, so no client-side lock is needed. - * 3. **Every failure degrades to today's per-directory install.** A host that cannot symlink, - * cannot create the directory, or answers nothing still deploys, one bundle at a time. - * - * Windows is deliberately excluded. node-pty's npm tarball ships win32 prebuilts, so there is no - * compile to avoid there, and `node-pty-1.1.0-console-list-agent-patch.cjs` mutates the installed - * tree in place — which rule 1 forbids for a shared one. - * - * Linux's `node-pty-1.1.0-master-cloexec-patch.cjs` also mutates in place, but it stays inside rule - * 1: the deploy path runs it before promotion, and returns early on a linked entry, so it only ever - * touches a private tree. Its bytes are in the key, so a patched build never links a pre-patch - * entry -- and a tree whose patch was refused or rolled back is not promoted at all, because under - * that same key it would publish the leak to every later host on the machine. - */ -import { createHash } from 'node:crypto' +/** Historical cache identities remain recognizable while older relay owners may use them. */ import { RELAY_REMOTE_DIR } from './relay-protocol' import { RELAY_BUILD_PLATFORMS } from '../../shared/relay-artifacts' import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' @@ -45,61 +13,12 @@ export const RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME = '.deps-complete' export const RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX = '.native-gc-' export const LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX = '.gc-tombstone.' -/** - * Bump when the remote install starts mutating the installed tree in a way the hashed inputs - * below cannot see — a new `npm rebuild` flag, a new post-install step, a patch applied by - * something other than a shipped `node-pty-*` artifact. A published entry is never repaired in - * place; only a new key retires it. - */ -export const RELAY_NATIVE_DEPS_CACHE_EPOCH = 1 - -/** - * Shipped relay artifacts that patch the installed native tree. Their bytes go into the key, so - * changing a patch mints a new entry instead of leaving hosts on a tree built from the old one. - */ -export const RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN = /^node-pty-.*\.(cjs|js|patch)$/ - const CACHE_KEY_HASH_LENGTH = 16 const CACHE_ENTRY_NAME_REGEX = new RegExp( `^(${RELAY_BUILD_PLATFORMS.join('|')})-[0-9a-f]{${CACHE_KEY_HASH_LENGTH}}$` ) -export type RelayNativeDepsCachePatchSource = { - filename: string - contents: string -} - -/** - * `-` over the dependency set, the epoch, and every patch the - * remote install applies. Platform and arch stay in the name rather than the hash so an operator - * reading `~/.orca-remote/native/` can tell what an entry is for. - */ -export function computeRelayNativeDepsCacheKey(input: { - platform: string - deps: Readonly> - patchSources?: readonly RelayNativeDepsCachePatchSource[] -}): string { - const hash = createHash('sha256') - hash.update(`epoch ${RELAY_NATIVE_DEPS_CACHE_EPOCH}\n`) - for (const [name, version] of Object.entries(input.deps).sort(([a], [b]) => (a < b ? -1 : 1))) { - hash.update(`dep ${name} ${version}\n`) - } - const patches = [...(input.patchSources ?? [])].sort((a, b) => (a.filename < b.filename ? -1 : 1)) - for (const patch of patches) { - hash.update( - `patch ${patch.filename} ${createHash('sha256').update(patch.contents).digest('hex')}\n` - ) - } - const key = `${input.platform}-${hash.digest('hex').slice(0, CACHE_KEY_HASH_LENGTH)}` - if (!isRelayNativeDepsCacheEntryName(key)) { - // Why: the key reaches the host inside `mv` and `rm -rf`; an unrecognized platform must - // disable the cache rather than arrive as a path fragment nobody validated. - throw new Error(`Unsafe relay native-deps cache key: ${JSON.stringify(key)}`) - } - return key -} - /** * Whether a name the host listed is one this client may move or delete. Every GC candidate goes * through here before it reaches a shell. @@ -143,10 +62,7 @@ export function relayNativeDepsCacheNodeModulesPath( return joinRemotePath(host, relayNativeDepsCacheEntryDir(host, remoteHome, key), 'node_modules') } -/** - * Windows hosts install node-pty from an npm prebuilt and then patch the tree in place, so they - * keep the per-directory install. Nothing else about their deploy changes. - */ +/** Older Windows relays never used the shared cache. */ export function supportsRelayNativeDepsCache(host: RemoteHostPlatform): boolean { return !isWindowsRemoteHost(host) }