diff options
| author | William Boman <william@redwill.se> | 2022-07-08 18:34:38 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-07-08 18:34:38 +0200 |
| commit | 976aa4fbee8a070f362cab6f6ec84e9251a90cf9 (patch) | |
| tree | 5e8d9c9c59444a25c7801b8f39763c4ba6e1f76d /lua/mason-core/installer | |
| parent | feat: add gotests, gomodifytags, impl (#28) (diff) | |
| download | mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.gz mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.bz2 mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.lz mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.xz mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.zst mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.zip | |
refactor: add mason-schemas and mason-core modules (#29)
* refactor: add mason-schemas and move generated filetype map to mason-lspconfig
* refactor: add mason-core module
Diffstat (limited to 'lua/mason-core/installer')
| -rw-r--r-- | lua/mason-core/installer/context.lua | 278 | ||||
| -rw-r--r-- | lua/mason-core/installer/handle.lua | 214 | ||||
| -rw-r--r-- | lua/mason-core/installer/init.lua | 176 | ||||
| -rw-r--r-- | lua/mason-core/installer/linker.lua | 84 |
4 files changed, 752 insertions, 0 deletions
diff --git a/lua/mason-core/installer/context.lua b/lua/mason-core/installer/context.lua new file mode 100644 index 00000000..9dfbb7f1 --- /dev/null +++ b/lua/mason-core/installer/context.lua @@ -0,0 +1,278 @@ +local spawn = require "mason-core.spawn" +local log = require "mason-core.log" +local fs = require "mason-core.fs" +local path = require "mason-core.path" +local platform = require "mason-core.platform" +local receipt = require "mason-core.receipt" +local Optional = require "mason-core.optional" +local _ = require "mason-core.functional" + +---@class ContextualSpawn +---@field cwd CwdManager +---@field handle InstallHandle +local ContextualSpawn = {} + +---@param cwd CwdManager +---@param handle InstallHandle +function ContextualSpawn.new(cwd, handle) + return setmetatable({ cwd = cwd, handle = handle }, ContextualSpawn) +end + +function ContextualSpawn.__index(self, cmd) + return function(args) + args.cwd = args.cwd or self.cwd:get() + args.stdio_sink = args.stdio_sink or self.handle.stdio.sink + local on_spawn = args.on_spawn + local captured_handle + args.on_spawn = function(handle, stdio, pid, ...) + captured_handle = handle + self.handle:push_spawninfo(handle, pid, cmd, spawn._flatten_cmd_args(args)) + if on_spawn then + on_spawn(handle, stdio, pid, ...) + end + end + local function pop_spawn_stack() + if captured_handle then + self.handle:pop_spawninfo(captured_handle) + end + end + -- We get_or_throw() here for convenience reasons. + -- Almost every time spawn is called via context we want the command to succeed. + return spawn[cmd](args):on_success(pop_spawn_stack):on_failure(pop_spawn_stack):get_or_throw() + end +end + +---@class ContextualFs +---@field private cwd CwdManager +local ContextualFs = {} +ContextualFs.__index = ContextualFs + +---@param cwd CwdManager +function ContextualFs.new(cwd) + return setmetatable({ cwd = cwd }, ContextualFs) +end + +---@async +---@param rel_path string @The relative path from the current working directory to the file to append. +---@param contents string +function ContextualFs:append_file(rel_path, contents) + return fs.async.append_file(path.concat { self.cwd:get(), rel_path }, contents) +end + +---@async +---@param rel_path string @The relative path from the current working directory to the file to write. +---@param contents string +function ContextualFs:write_file(rel_path, contents) + return fs.async.write_file(path.concat { self.cwd:get(), rel_path }, contents) +end + +---@async +---@param rel_path string @The relative path from the current working directory. +function ContextualFs:file_exists(rel_path) + return fs.async.file_exists(path.concat { self.cwd:get(), rel_path }) +end + +---@async +---@param rel_path string @The relative path from the current working directory. +function ContextualFs:dir_exists(rel_path) + return fs.async.dir_exists(path.concat { self.cwd:get(), rel_path }) +end + +---@async +---@param rel_path string @The relative path from the current working directory. +function ContextualFs:rmrf(rel_path) + return fs.async.rmrf(path.concat { self.cwd:get(), rel_path }) +end + +---@async +---@param rel_path string @The relative path from the current working directory. +function ContextualFs:unlink(rel_path) + return fs.async.unlink(path.concat { self.cwd:get(), rel_path }) +end + +---@async +---@param old_path string +---@param new_path string +function ContextualFs:rename(old_path, new_path) + return fs.async.rename(path.concat { self.cwd:get(), old_path }, path.concat { self.cwd:get(), new_path }) +end + +---@async +---@param dirpath string +function ContextualFs:mkdir(dirpath) + return fs.async.mkdir(path.concat { self.cwd:get(), dirpath }) +end + +---@class CwdManager +---@field private install_prefix string @Defines the upper boundary for which paths are allowed as cwd. +---@field private cwd string +local CwdManager = {} +CwdManager.__index = CwdManager + +function CwdManager.new(install_prefix) + assert(type(install_prefix) == "string") + return setmetatable({ + install_prefix = install_prefix, + cwd = nil, + }, CwdManager) +end + +function CwdManager:get() + assert(self.cwd ~= nil, "Tried to access cwd before it was set.") + return self.cwd +end + +---@param new_cwd string +function CwdManager:set(new_cwd) + assert(type(new_cwd) == "string") + assert( + path.is_subdirectory(self.install_prefix, new_cwd), + ("%q is not a subdirectory of %q"):format(new_cwd, self.install_prefix) + ) + self.cwd = new_cwd +end + +---@class InstallContext +---@field public receipt InstallReceiptBuilder +---@field public requested_version Optional +---@field public fs ContextualFs +---@field public spawn JobSpawn +---@field public handle InstallHandle +---@field public package Package +---@field public cwd CwdManager +---@field public stdio_sink StdioSink +local InstallContext = {} +InstallContext.__index = InstallContext + +---@class InstallContextOpts +---@field requested_version string|nil + +---@param handle InstallHandle +---@param opts InstallContextOpts +function InstallContext.new(handle, opts) + local cwd_manager = CwdManager.new(path.install_prefix()) + return setmetatable({ + cwd = cwd_manager, + spawn = ContextualSpawn.new(cwd_manager, handle), + handle = handle, + package = handle.package, -- for convenience + fs = ContextualFs.new(cwd_manager), + receipt = receipt.InstallReceiptBuilder.new(), + requested_version = Optional.of_nilable(opts.requested_version), + stdio_sink = handle.stdio.sink, + }, InstallContext) +end + +---@async +function InstallContext:promote_cwd() + local cwd = self.cwd:get() + local install_path = self.package:get_install_path() + if install_path == cwd then + log.fmt_debug("cwd %s is already promoted (at %s)", cwd, install_path) + return + end + log.fmt_debug("Promoting cwd %s to %s", cwd, install_path) + -- 1. Unlink any existing installation + self.handle.package:unlink() + -- 2. Prepare for renaming cwd to destination + if platform.is_unix then + -- Some Unix systems will raise an error when renaming a directory to a destination that does not already exist. + fs.async.mkdir(install_path) + end + -- 3. Move the cwd to the final installation directory + fs.async.rename(cwd, install_path) + -- 4. Update cwd + self.cwd:set(install_path) +end + +---@param rel_path string @The relative path from the current working directory to change cwd to. Will only restore to the initial cwd after execution of fn (if provided). +---@param fn async fun() @(optional) The function to run in the context of the given path. +function InstallContext:chdir(rel_path, fn) + local old_cwd = self.cwd:get() + self.cwd:set(path.concat { old_cwd, rel_path }) + if fn then + local ok, result = pcall(fn) + self.cwd:set(old_cwd) + if not ok then + error(result, 0) + end + return result + end +end + +---@param new_executable_rel_path string @Relative path to the executable file to create. +---@param script_rel_path string @Relative path to the Node.js script. +function InstallContext:write_node_exec_wrapper(new_executable_rel_path, script_rel_path) + return self:write_shell_exec_wrapper( + new_executable_rel_path, + ("node %q"):format(path.concat { + self.package:get_install_path(), + script_rel_path, + }) + ) +end + +---@param new_executable_rel_path string @Relative path to the executable file to create. +---@param target_executable_rel_path string +function InstallContext:write_exec_wrapper(new_executable_rel_path, target_executable_rel_path) + return self:write_shell_exec_wrapper( + new_executable_rel_path, + ("%q"):format(path.concat { + self.package:get_install_path(), + target_executable_rel_path, + }) + ) +end + +---@param new_executable_rel_path string @Relative path to the executable file to create. +---@param command string @The shell command to run. +---@return string @The created executable filename. +function InstallContext:write_shell_exec_wrapper(new_executable_rel_path, command) + return platform.when { + unix = function() + local std = require "mason-core.managers.std" + self.fs:write_file( + new_executable_rel_path, + _.dedent(([[ + #!/bin/bash + exec %s "$@" + ]]):format(command)) + ) + std.chmod("+x", { new_executable_rel_path }) + return new_executable_rel_path + end, + win = function() + local executable_file = ("%s.cmd"):format(new_executable_rel_path) + self.fs:write_file( + executable_file, + _.dedent(([[ + @ECHO off + %s %%* + ]]):format(command)) + ) + return executable_file + end, + } +end + +function InstallContext:link_bin(executable, rel_path) + self.receipt:with_link("bin", executable, rel_path) +end + +---@param patches string[] +function InstallContext:apply_patches(patches) + for _, patch in ipairs(patches) do + self.spawn.patch { + "-g", + "0", + "-f", + on_spawn = function(_, stdio) + local stdin = stdio[1] + stdin:write(patch) + stdin:close() + end, + } + end +end + +return InstallContext diff --git a/lua/mason-core/installer/handle.lua b/lua/mason-core/installer/handle.lua new file mode 100644 index 00000000..459e3704 --- /dev/null +++ b/lua/mason-core/installer/handle.lua @@ -0,0 +1,214 @@ +local a = require "mason-core.async" +local spawn = require "mason-core.spawn" +local _ = require "mason-core.functional" +local process = require "mason-core.process" +local EventEmitter = require "mason-core.EventEmitter" +local log = require "mason-core.log" +local Optional = require "mason-core.optional" +local platform = require "mason-core.platform" + +local uv = vim.loop + +---@alias InstallHandleState +--- | '"IDLE"' +--- | '"QUEUED"' +--- | '"ACTIVE"' +--- | '"CLOSED"' + +---@class InstallHandleSpawnInfo +---@field handle luv_handle +---@field pid integer +---@field cmd string +---@field args string[] +local InstallHandleSpawnInfo = {} +InstallHandleSpawnInfo.__index = InstallHandleSpawnInfo + +---@param fields InstallHandleSpawnInfo +function InstallHandleSpawnInfo.new(fields) + return setmetatable(fields, InstallHandleSpawnInfo) +end + +function InstallHandleSpawnInfo:__tostring() + return ("%s %s"):format(self.cmd, table.concat(self.args, " ")) +end + +---@class InstallHandle : EventEmitter +---@field package Package +---@field state InstallHandleState +---@field stdio { buffers: { stdout: string[], stderr: string[] }, sink: StdioSink } +---@field is_terminated boolean +---@field private spawninfo_stack InstallHandleSpawnInfo[] +local InstallHandle = setmetatable({}, { __index = EventEmitter }) +local InstallHandleMt = { __index = InstallHandle } + +---@param handle InstallHandle +local function new_sink(handle) + local stdout, stderr = {}, {} + return { + buffers = { stdout = stdout, stderr = stderr }, + sink = { + stdout = function(chunk) + stdout[#stdout + 1] = chunk + handle:emit("stdout", chunk) + end, + stderr = function(chunk) + stderr[#stderr + 1] = chunk + handle:emit("stderr", chunk) + end, + }, + } +end + +---@param pkg Package +function InstallHandle.new(pkg) + local self = EventEmitter.init(setmetatable({}, InstallHandleMt)) + self.state = "IDLE" + self.package = pkg + self.spawninfo_stack = {} + self.stdio = new_sink(self) + self.is_terminated = false + return self +end + +---@param luv_handle luv_handle +---@param pid integer +---@param cmd string +---@param args string[] +function InstallHandle:push_spawninfo(luv_handle, pid, cmd, args) + local spawninfo = InstallHandleSpawnInfo.new { + handle = luv_handle, + pid = pid, + cmd = cmd, + args = args, + } + log.fmt_trace("Pushing spawninfo stack for %s: %s (pid: %s)", self, spawninfo, pid) + self.spawninfo_stack[#self.spawninfo_stack + 1] = spawninfo + self:emit "spawninfo:change" +end + +---@param luv_handle luv_handle +function InstallHandle:pop_spawninfo(luv_handle) + for i = #self.spawninfo_stack, 1, -1 do + if self.spawninfo_stack[i].handle == luv_handle then + log.fmt_trace("Popping spawninfo stack for %s: %s", self, self.spawninfo_stack[i]) + table.remove(self.spawninfo_stack, i) + self:emit "spawninfo:change" + return true + end + end + return false +end + +---@return Optional @Optional<InstallHandleSpawnInfo> +function InstallHandle:peek_spawninfo_stack() + return Optional.of_nilable(self.spawninfo_stack[#self.spawninfo_stack]) +end + +function InstallHandle:is_idle() + return self.state == "IDLE" +end + +function InstallHandle:is_queued() + return self.state == "QUEUED" +end + +function InstallHandle:is_active() + return self.state == "ACTIVE" +end + +function InstallHandle:is_closed() + return self.state == "CLOSED" +end + +---@param new_state InstallHandleState +function InstallHandle:set_state(new_state) + local old_state = self.state + self.state = new_state + log.fmt_trace("Changing %s state from %s to %s", self, old_state, new_state) + self:emit("state:change", new_state, old_state) +end + +---@param signal integer +function InstallHandle:kill(signal) + assert(not self:is_closed(), "Cannot kill closed handle.") + log.fmt_trace("Sending signal %s to luv handles in %s", signal, self) + for _, spawninfo in pairs(self.spawninfo_stack) do + process.kill(spawninfo.handle, signal) + end + self:emit("kill", signal) +end + +---@param pid integer +local win_taskkill = a.scope(function(pid) + spawn.taskkill { + "/f", + "/t", + "/pid", + pid, + } +end) + +function InstallHandle:terminate() + assert(not self:is_closed(), "Cannot terminate closed handle.") + if self.is_terminated then + log.fmt_trace("Handle is already terminated %s", self) + return + end + log.fmt_trace("Terminating %s", self) + -- https://github.com/libuv/libuv/issues/1133 + if platform.is.win then + for _, spawninfo in ipairs(self.spawninfo_stack) do + win_taskkill(spawninfo.pid) + end + else + self:kill(15) -- SIGTERM + end + self.is_terminated = true + self:emit "terminate" + local check = uv.new_check() + check:start(function() + for _, spawninfo in ipairs(self.spawninfo_stack) do + local luv_handle = spawninfo.handle + local ok, is_closing = pcall(luv_handle.is_closing, luv_handle) + if ok and not is_closing then + return + end + end + check:stop() + if not self:is_closed() then + self:close() + end + end) +end + +function InstallHandle:queued() + assert(self:is_idle(), "Can only queue idle handles.") + self:set_state "QUEUED" +end + +function InstallHandle:active() + assert(self:is_idle() or self:is_queued(), "Can only activate idle or queued handles.") + self:set_state "ACTIVE" +end + +function InstallHandle:close() + log.fmt_trace("Closing %s", self) + assert(not self:is_closed(), "Handle is already closed.") + for _, spawninfo in ipairs(self.spawninfo_stack) do + local luv_handle = spawninfo.handle + local ok, is_closing = pcall(luv_handle.is_closing, luv_handle) + if ok then + assert(is_closing, "There are open libuv handles.") + end + end + self.spawninfo_stack = {} + self:set_state "CLOSED" + self:emit "closed" + self:clear_event_handlers() +end + +function InstallHandleMt:__tostring() + return ("InstallHandle(package=%s, state=%s)"):format(self.package, self.state) +end + +return InstallHandle diff --git a/lua/mason-core/installer/init.lua b/lua/mason-core/installer/init.lua new file mode 100644 index 00000000..ecf4f2f0 --- /dev/null +++ b/lua/mason-core/installer/init.lua @@ -0,0 +1,176 @@ +local log = require "mason-core.log" +local _ = require "mason-core.functional" +local path = require "mason-core.path" +local fs = require "mason-core.fs" +local a = require "mason-core.async" +local Result = require "mason-core.result" +local InstallContext = require "mason-core.installer.context" +local settings = require "mason.settings" +local linker = require "mason-core.installer.linker" +local control = require "mason-core.async.control" + +local Semaphore = control.Semaphore + +local sem = Semaphore.new(settings.current.max_concurrent_installers) + +local M = {} + +---@async +local function create_prefix_dirs() + for _, p in ipairs { path.install_prefix(), path.bin_prefix(), path.package_prefix(), path.package_build_prefix() } do + if not fs.async.dir_exists(p) then + fs.async.mkdirp(p) + end + end +end + +---@async +---@param context InstallContext +local function write_receipt(context) + log.fmt_debug("Writing receipt for %s", context.package) + context.receipt + :with_name(context.package.name) + :with_schema_version("1.0") + :with_completion_time(vim.loop.gettimeofday()) + local receipt_path = path.concat { context.cwd:get(), "mason-receipt.json" } + local install_receipt = context.receipt:build() + fs.async.write_file(receipt_path, vim.json.encode(install_receipt)) +end + +local CONTEXT_REQUEST = {} + +---@return InstallContext +function M.context() + return coroutine.yield(CONTEXT_REQUEST) +end + +---@async +---@param context InstallContext +function M.prepare_installer(context) + create_prefix_dirs() + local package_build_prefix = path.package_build_prefix(context.package.name) + if fs.async.dir_exists(package_build_prefix) then + fs.async.rmrf(package_build_prefix) + end + fs.async.mkdirp(package_build_prefix) + context.cwd:set(package_build_prefix) +end + +---@async +---@param context InstallContext +---@param installer async fun(context: InstallContext) +function M.run_installer(context, installer) + local thread = coroutine.create(function(...) + -- We wrap the installer with a function to allow it to be a spy instance (in which case it's not a function, but a metatable - coroutine.create expects functions only) + return installer(...) + end) + local step + local ret_val + step = function(...) + local ok, result = coroutine.resume(thread, ...) + if not ok then + error(result, 0) + elseif result == CONTEXT_REQUEST then + step(context) + elseif coroutine.status(thread) == "suspended" then + -- yield to parent coroutine + step(coroutine.yield(result)) + else + ret_val = result + end + end + context.receipt:with_start_time(vim.loop.gettimeofday()) + M.prepare_installer(context) + step(context) + return ret_val +end + +---@async +---@param handle InstallHandle +---@param opts InstallContextOpts +function M.execute(handle, opts) + if handle:is_active() or handle:is_closed() then + log.fmt_debug("Received active or closed handle %s", handle) + return Result.failure "Invalid handle state." + end + + handle:queued() + local permit = sem:acquire() + if handle:is_closed() then + permit:forget() + log.fmt_trace("Installation was aborted %s", handle) + return Result.failure "Installation was aborted." + end + log.fmt_trace("Activating handle %s", handle) + handle:active() + + local pkg = handle.package + local context = InstallContext.new(handle, opts) + + log.fmt_info("Executing installer for %s", pkg) + return Result.run_catching(function() + -- 1. run installer + a.wait(function(resolve, reject) + local cancel_thread = a.run(M.run_installer, function(success, result) + if success then + resolve(result) + else + reject(result) + end + end, context, pkg.spec.install) + + handle:once("terminate", function() + handle:once("closed", function() + reject "Installation was aborted." + end) + cancel_thread() + end) + end) + + -- 2. promote temporary installation dir + context:promote_cwd() + + -- 3. link package + linker.link(context) + + -- 4. write receipt + write_receipt(context) + end) + :on_success(function() + permit:forget() + handle:close() + log.fmt_info("Installation succeeded for %s", pkg) + end) + :on_failure(function(failure) + permit:forget() + log.fmt_error("Installation failed for %s error=%s", pkg, failure) + context.stdio_sink.stderr(tostring(failure)) + context.stdio_sink.stderr "\n" + + -- clean up installation dir + pcall(function() + fs.async.rmrf(context.cwd:get()) + end) + + -- unlink linked executables (in the rare occassion an error occurs after linking) + linker.unlink(context.package, context.receipt.links) + + if not handle:is_closed() and not handle.is_terminated then + handle:close() + end + end) +end + +---Runs the provided async functions concurrently and returns their result, once all are resolved. +---This is really just a wrapper around a.wait_all() that makes sure to patch the coroutine context before creating the +---new async execution contexts. +---@async +---@param suspend_fns async fun(ctx: InstallContext)[] +function M.run_concurrently(suspend_fns) + local context = M.context() + return a.wait_all(_.map(function(suspend_fn) + return _.partial(M.run_installer, context, suspend_fn) + end, suspend_fns)) +end + +return M diff --git a/lua/mason-core/installer/linker.lua b/lua/mason-core/installer/linker.lua new file mode 100644 index 00000000..0419f392 --- /dev/null +++ b/lua/mason-core/installer/linker.lua @@ -0,0 +1,84 @@ +local path = require "mason-core.path" +local platform = require "mason-core.platform" +local _ = require "mason-core.functional" +local log = require "mason-core.log" +local fs = require "mason-core.fs" + +local M = {} + +---@param pkg Package +---@param links InstallReceiptLinks +local function unlink_bin(pkg, links) + for executable in pairs(links.bin) do + local bin_path = path.bin_prefix(executable) + fs.sync.unlink(bin_path) + end +end + +---@param pkg Package +---@param links InstallReceiptLinks +function M.unlink(pkg, links) + log.fmt_debug("Unlinking %s", pkg) + unlink_bin(pkg, links) +end + +---@param to string +local function relative_path_from_bin(to) + local _, match_end = to:find(path.install_prefix(), 1, true) + assert(match_end, "Failed to produce relative path.") + local relative_path = to:sub(match_end + 1) + return ".." .. relative_path +end + +---@async +---@param context InstallContext +local function link_bin(context) + local links = context.receipt.links.bin + local pkg = context.package + for name, rel_path in pairs(links) do + local target_abs_path = path.concat { pkg:get_install_path(), rel_path } + local target_rel_path = relative_path_from_bin(target_abs_path) + local bin_path = path.bin_prefix(name) + + assert(not fs.async.file_exists(bin_path), ("bin/%s is already linked."):format(name)) + assert(fs.async.file_exists(target_abs_path), ("Link target %q does not exist."):format(target_abs_path)) + + log.fmt_debug("Linking bin %s to %s", name, target_rel_path) + + platform.when { + unix = function() + fs.async.symlink(target_rel_path, bin_path) + end, + win = function() + -- We don't "symlink" on Windows because: + -- 1) .LNK is not commonly found in PATHEXT + -- 2) some executables can only run from their true installation location + -- 3) many utilities only consider .COM, .EXE, .CMD, .BAT files as candidates by default when resolving executables (e.g. neovim's |exepath()| and |executable()|) + fs.async.write_file( + ("%s.cmd"):format(bin_path), + _.dedent(([[ + @ECHO off + GOTO start + :find_dp0 + SET dp0=%%~dp0 + EXIT /b + :start + SETLOCAL + CALL :find_dp0 + + endLocal & goto #_undefined_# 2>NUL || title %%COMSPEC%% & "%%dp0%%\%s" %%* + ]]):format(target_rel_path)) + ) + end, + } + end +end + +---@async +---@param context InstallContext +function M.link(context) + log.fmt_debug("Linking %s", context.package) + link_bin(context) +end + +return M |
