--!A cross-platform build utility based on Lua -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. -- You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- -- Unless required by applicable law or agreed to in writing, software -- distributed under the License is distributed on an "AS IS" BASIS, -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. -- -- Copyright (C) 2015-present, Xmake Open Source Community. -- -- @author ruki -- @file client.lua -- -- imports import("core.base.bytes") import("core.base.base64") import("core.base.socket") import("core.base.option") import("core.base.scheduler") import("core.project.policy") import("core.project.project") import("core.project.config", {alias = "project_config"}) import("lib.detect.find_tool") import("private.service.client_config", {alias = "config"}) import("private.service.message") import("private.service.client") import("private.cache.build_cache") import("private.service.distcc_build.client_session") import("private.service.stream", {alias = "socket_stream"}) -- define module local distcc_build_client = distcc_build_client or client() local super = distcc_build_client:class() -- init client function distcc_build_client:init() super.init(self) -- init hosts local hosts = assert(config.get("distcc_build.hosts"), "config(distcc_build.hosts): not found!") self:hosts_set(hosts) -- get project directory local projectdir = os.projectdir() local projectfile = os.projectfile() if projectfile and os.isfile(projectfile) and projectdir then self._PROJECTDIR = projectdir self._WORKDIR = path.join(project_config.directory(), "service", "distcc_build") else raise("we need to enter a project directory with xmake.lua first!") end -- init timeout self._SEND_TIMEOUT = config.get("distcc_build.send_timeout") or config.get("send_timeout") or -1 self._RECV_TIMEOUT = config.get("distcc_build.recv_timeout") or config.get("recv_timeout") or -1 self._CONNECT_TIMEOUT = config.get("distcc_build.connect_timeout") or config.get("connect_timeout") or 10000 end -- get class function distcc_build_client:class() return distcc_build_client end -- get the client hosts function distcc_build_client:hosts() return self._HOSTS end -- set the client hosts function distcc_build_client:hosts_set(hosts) local hostinfos = {} for _, host in ipairs(hosts) do local hostinfo = {} local address = assert(host.connect, "connect address not found in hosts configuration!") local addr, port, user = self:address_parse(address) if addr and port then hostinfo.addr = addr hostinfo.port = port hostinfo.user = user hostinfo.token = host.token hostinfo.njob = host.njob table.insert(hostinfos, hostinfo) end end self._HOSTS = hostinfos end -- get the status of client hosts function distcc_build_client:hosts_status() local hosts_status = self._HOSTS_STATUS if hosts_status == nil then hosts_status = table.copy(self:status().hosts) for _, host_status in pairs(hosts_status) do self:_host_status_update(host_status) end self._HOSTS_STATUS = hosts_status end return hosts_status end -- connect to the distcc server function distcc_build_client:connect() if self:is_connected() then print("%s: has been connected!", self) return end -- do connect local hosts = self:hosts() assert(hosts and #hosts > 0, "hosts not found!") local group_name = tostring(self) .. "/connect" scheduler.co_group_begin(group_name, function () for _, host in ipairs(hosts) do scheduler.co_start(self._connect_host, self, host) end end) scheduler.co_group_wait(group_name) -- all hosts are connected? local connected = true for _, host in ipairs(hosts) do if not self:_is_connected(host.addr, host.port) then connected = false end end -- update status local status = self:status() status.connected = connected self:status_save() end -- disconnect server function distcc_build_client:disconnect() if not self:is_connected() then print("%s: has been disconnected!", self) return end -- do disconnect local hosts = self:hosts() assert(hosts and #hosts > 0, "hosts not found!") for _, host in ipairs(hosts) do self:_disconnect_host(host) end -- all hosts are connected? local connected = true for _, host in ipairs(hosts) do if not self:_is_connected(host.addr, host.port) then connected = false end end -- update status local status = self:status() status.connected = connected self:status_save() end -- is connected? function distcc_build_client:is_connected() return self:status().connected end -- get max jobs count function distcc_build_client:maxjobs() local maxjobs = self._MAXJOBS if maxjobs == nil then maxjobs = 0 for _, host in pairs(self:status().hosts) do maxjobs = maxjobs + (host.njob or 4) end self._MAXJOBS = maxjobs end return maxjobs or 0 end -- get free jobs count function distcc_build_client:freejobs() local maxjobs = self:maxjobs() if maxjobs > 0 then local running = (self._RUNNING or 0) if running > maxjobs then running = maxjobs end return maxjobs - running end return 0 end -- has free jobs? function distcc_build_client:has_freejobs() return self:freejobs() > 0 end -- clean server files function distcc_build_client:clean() if not self:is_connected() then print("%s: has been disconnected!", self) return end -- do disconnect local hosts = self:hosts() assert(hosts and #hosts > 0, "hosts not found!") local group_name = tostring(self) .. "/clean" scheduler.co_group_begin(group_name, function () for _, host in ipairs(hosts) do scheduler.co_start(self._clean_host, self, host) end end) scheduler.co_group_wait(group_name) end -- run compilation job function distcc_build_client:compile(program, argv, opt) -- get free host local host = assert(self:_get_freehost(), "free host not found!") -- lock this host self:_host_status_lock(host) -- open the host session local session = assert(self:_host_status_session_open(host), "open session failed!") -- do preprocess opt = opt or {} local preprocess = assert(opt.preprocess, "preprocessor not found!") local cppinfo = preprocess(program, argv, opt) local build_in_local = false if cppinfo then -- get objectfile from the build cache first local cached = false local cachekey if build_cache.is_enabled(opt.target) then cachekey = build_cache.cachekey(program, cppinfo, opt.envs) local objectfile_cached, objectfile_infofile = build_cache.get(cachekey) if objectfile_cached then os.cp(objectfile_cached, cppinfo.objectfile) -- we need to update mtime for incremental compilation -- @see https://github.com/xmake-io/xmake/issues/2620 os.touch(cppinfo.objectfile, {mtime = os.time()}) -- we need to get outdata/errdata to show warnings, -- @see https://github.com/xmake-io/xmake/issues/2452 if objectfile_infofile and os.isfile(objectfile_infofile) then local extrainfo = io.load(objectfile_infofile) cppinfo.outdata = extrainfo.outdata cppinfo.errdata = extrainfo.errdata end cached = true end end -- do distcc compilation if not cached then -- we just compile the large preprocessed file in remote if (self:remote_only() or os.filesize(cppinfo.cppfile) > 4096) and not session:is_unreachable() then local compile_fallback = opt.compile_fallback if compile_fallback then local ok = try { function () local outdata, errdata = session:compile(cppinfo.sourcefile, cppinfo.objectfile, cppinfo.cppfile, cppinfo.cppflags, table.join(opt, {cachekey = cachekey})) cppinfo.outdata = outdata cppinfo.errdata = errdata return true end, catch { function (errors) if errors and policy.build_warnings() then cprint("${color.warning}fallback to the local compiler, %s", tostring(errors)) end end } } if not ok then -- we fallback to compile original source file if compiling preprocessed file fails. -- https://github.com/xmake-io/xmake/issues/2467 local outdata, errdata = compile_fallback() cppinfo.outdata = outdata cppinfo.errdata = errdata end else local outdata, errdata = session:compile(cppinfo.sourcefile, cppinfo.objectfile, cppinfo.cppfile, cppinfo.cppflags, table.join(opt, {cachekey = cachekey})) cppinfo.outdata = outdata cppinfo.errdata = errdata end if cachekey then local extrainfo if cppinfo.outdata and #cppinfo.outdata ~= 0 then extrainfo = extrainfo or {} extrainfo.outdata = cppinfo.outdata end if cppinfo.errdata and #cppinfo.errdata ~= 0 then extrainfo = extrainfo or {} extrainfo.errdata = cppinfo.errdata end build_cache.put(cachekey, cppinfo.objectfile, extrainfo) end else build_in_local = true end end end -- close session self:_host_status_session_close(host, session) -- unlock this host self:_host_status_unlock(host) -- build in local if build_in_local then if cppinfo and build_in_local then local compile = assert(opt.compile, "compiler not found!") local compile_fallback = opt.compile_fallback if compile_fallback then local ok = try {function () compile(program, cppinfo, opt); return true end} if not ok then -- we fallback to compile original source file if compiling preprocessed file fails. -- https://github.com/xmake-io/xmake/issues/2467 local outdata, errdata = compile_fallback() cppinfo.outdata = outdata cppinfo.errdata = errdata end else compile(program, cppinfo, opt) end if build_cache.is_enabled(opt.target) then local cachekey = build_cache.cachekey(program, cppinfo, opt.envs) if cachekey then local extrainfo if cppinfo.outdata and #cppinfo.outdata ~= 0 then extrainfo = extrainfo or {} extrainfo.outdata = cppinfo.outdata end if cppinfo.errdata and #cppinfo.errdata ~= 0 then extrainfo = extrainfo or {} extrainfo.errdata = cppinfo.errdata end build_cache.put(cachekey, cppinfo.objectfile, extrainfo) end end end end return cppinfo end -- get the status function distcc_build_client:status() local status = self._STATUS local statusfile = self:statusfile() if not status then if os.isfile(statusfile) then status = io.load(statusfile) end status = status or {} self._STATUS = status end return status end -- save status function distcc_build_client:status_save() io.save(self:statusfile(), self:status()) end -- get the status file function distcc_build_client:statusfile() return path.join(self:workdir(), "status.txt") end -- get the project directory function distcc_build_client:projectdir() return self._PROJECTDIR end -- get working directory function distcc_build_client:workdir() return self._WORKDIR end -- build on only remote machines function distcc_build_client:remote_only() return project.policy("build.distcc.remote_only") == true end -- get free host function distcc_build_client:_get_freehost() local max_weight = -1 local host if self:has_freejobs() then for _, host_status in pairs(self:hosts_status()) do if host_status.freejobs > 0 and host_status.weight > max_weight then max_weight = host_status.weight host = host_status end end end return host end -- update the host status function distcc_build_client:_host_status_update(host_status) local running = host_status.running or 0 if running > host_status.njob then running = host_status.njob end if running < 0 then running = 0 end host_status.running = running host_status.freejobs = host_status.njob - host_status.running host_status.weight = host_status.freejobs * (1.0 - (host_status.cpurate or 0)) if host_status.memrate and host_status.memrate > 0.9 then host_status.weight = host_status.weight * (1.0 - (host_status.memrate or 0)) end end -- lock host status function distcc_build_client:_host_status_lock(host_status) -- update the host status host_status.running = host_status.running + 1 self:_host_status_update(host_status) -- update the total running status local running = (self._RUNNING or 0) + 1 if running > self:maxjobs() then running = self:maxjobs() end self._RUNNING = running end -- unlock host status function distcc_build_client:_host_status_unlock(host_status) -- update the host status host_status.running = host_status.running - 1 self:_host_status_update(host_status) -- update the total running status local running = self._RUNNING - 1 if running < 0 then running = 0 end self._RUNNING = running end -- open host session function distcc_build_client:_host_status_session_open(host_status) host_status.sessions = host_status.sessions or {} host_status.free_sessions = host_status.free_sessions or {} local sessions = host_status.sessions local free_sessions = host_status.free_sessions if #free_sessions > 0 then local session = free_sessions[#free_sessions] if session and not session:is_opened() then table.remove(free_sessions) session:open(host_status) return session end end local njob = host_status.njob for i = 1, njob do local session = host_status.sessions[i] if not session then session = client_session(self, host_status.session_id, host_status.token, host_status.addr, host_status.port, {send_timeout = self:send_timeout(), recv_timeout = self:recv_timeout(), connect_timeout = self:connect_timeout()}) host_status.sessions[i] = session session:open(host_status) return session elseif not session:is_opened() then session:open(host_status) return session end end end -- close session function distcc_build_client:_host_status_session_close(host_status, session) host_status.free_sessions = host_status.free_sessions or {} session:close() table.insert(host_status.free_sessions, session) end -- get the session id, only for unique project function distcc_build_client:_session_id(addr, port) local hosts = self:status().hosts if hosts then local host = hosts[addr .. ":" .. port] if host then return host.session_id end end return hash.uuid(option.get("session")):split("-", {plain = true})[1]:lower() end -- is connected for the given host function distcc_build_client:_is_connected(addr, port) local hosts = self:status().hosts if hosts then local host = hosts[addr .. ":" .. port] if host then return host.connected end end end -- connect to the host function distcc_build_client:_connect_host(host) local addr = host.addr local port = host.port if self:_is_connected(addr, port) then print("%s: %s:%d has been connected!", self, addr, port) return end -- Do we need user authorization? local user = host.user local token = host.token if not token and user then -- get user password cprint("Please input user ${bright}%s${clear} password to connect <%s:%d>:", user, addr, port) io.flush() local pass = (io.read() or ""):trim() assert(pass ~= "", "password is empty!") -- compute user authorization token = base64.encode(self:user() .. ":" .. pass) token = hash.md5(bytes(token)) end -- do connect local sock = assert(socket.connect(addr, port, {timeout = self:connect_timeout()}), "%s: server unreachable!", self) local session_id = self:_session_id(addr, port) local ok = false local errors local ncpu, njob print("%s: connect %s:%d ..", self, addr, port) if sock then local stream = socket_stream(sock, {send_timeout = self:send_timeout(), recv_timeout = self:recv_timeout()}) if stream:send_msg(message.new_connect(session_id, {token = token})) and stream:flush() then local msg = stream:recv_msg() if msg then local body = msg:body() vprint(body) if msg:success() then ncpu = body.ncpu njob = body.njob ok = true else errors = msg:errors() end end end end if ok then print("%s: %s:%d connected!", self, addr, port) else print("%s: connect %s:%d failed, %s", self, addr, port, errors or "unknown") end -- update status if ok then local status = self:status() status.hosts = status.hosts or {} status.hosts[addr .. ":" .. port] = { addr = addr, port = port, token = token, connected = ok, session_id = session_id, ncpu = ncpu, njob = njob} self:status_save() end end -- disconnect from the host function distcc_build_client:_disconnect_host(host) local addr = host.addr local port = host.port if not self:_is_connected(addr, port) then print("%s: %s:%d has been disconnected!", self, addr, port) return end -- update status local status = self:status() status.hosts = status.hosts or {} local host_status = status.hosts[addr .. ":" .. port] if host_status then host_status.token = nil host_status.connected = false end self:status_save() print("%s: %s:%d disconnected!", self, addr, port) end -- clean file for the host function distcc_build_client:_clean_host(host) local addr = host.addr local port = host.port if not self:_is_connected(addr, port) then print("%s: %s:%d has been disconnected!", self, addr, port) return end -- do clean local token = host.token local sock = socket.connect(addr, port, {timeout = self:connect_timeout()}) local session_id = self:_session_id(addr, port) local errors local ok = false print("%s: clean files in %s:%d ..", self, addr, port) if sock then local stream = socket_stream(sock, {send_timeout = self:send_timeout(), recv_timeout = self:recv_timeout()}) if stream:send_msg(message.new_clean(session_id, {token = token})) and stream:flush() then local msg = stream:recv_msg() if msg then vprint(msg:body()) if msg:success() then ok = true else errors = msg:errors() end end end else -- server unreachable, but we still disconnect it. wprint("%s: server unreachable!", self) ok = true end if ok then print("%s: %s:%d clean files ok!", self, addr, port) else print("%s: clean files %s:%d failed, %s", self, addr, port, errors or "unknown") end end -- is connected? we cannot depend on client:init when run action function is_connected() local connected = _g.connected if connected == nil then -- the current process is in service? we cannot enable it if os.getenv("XMAKE_IN_SERVICE") then connected = false end if connected == nil then local projectdir = os.projectdir() local projectfile = os.projectfile() if projectfile and os.isfile(projectfile) and projectdir then local workdir = path.join(project_config.directory(), "service", "distcc_build") local statusfile = path.join(workdir, "status.txt") if os.isfile(statusfile) then local status = io.load(statusfile) if status and status.connected then connected = true end end end end connected = connected or false _g.connected = connected end return connected end -- we can run distcc job? function is_distccjob() if is_connected() then local co_running = scheduler.co_running() if co_running then return co_running:data("distcc.distccjob") end end end -- new a client instance function new() local instance = distcc_build_client() instance:init() return instance end -- get the singleton function singleton() local instance = _g.singleton if not instance then config.load() instance = new() _g.singleton = instance end return instance end function main() return new() end