module Precompilation

using Base: CoreLogging, PkgId, UUID, SHA1, StaleCacheKey, parsed_toml,
            project_file_manifest_path, get_deps, preferences_names,
            base_project, isdefined

const Config = Pair{Cmd, Base.CacheFlags}
const PkgConfig = Tuple{PkgId,Config}

# --- Precompile jobserver (JuliaLang/julia#58591) ----------------------------
# A token pool of size `ntokens` shared with worker subprocesses that caps total
# CPU threads across all parallel workers: each holds one baseline token while
# CPU-active and its AOT imaging phase draws extra codegen threads from the same
# pool. Only one pool exists per process; a concurrent session (e.g. from package
# loading) whose create fails joins the existing pool instead, holding baselines
# against it without tearing it down. Returns:
#   :created -- created and owns the pool (must tear it down)
#   :joined  -- sharing a pool another session in this process owns
#   :none    -- no pool available; run uncoordinated
function setup_precompile_jobserver!(ntokens::Int)
    namep = ccall(:jl_precompile_jobserver_create, Ptr{UInt8}, (Cint,), ntokens)
    if namep != C_NULL
        ENV["JULIA_PRECOMPILE_JOBSERVER"] = unsafe_string(namep)
        return :created
    end
    # create failed: join an existing pool, else run uncoordinated (OS refused it)
    ccall(:jl_precompile_jobserver_active, Cint, ()) != 0 ? :joined : :none
end

function teardown_precompile_jobserver!()
    ccall(:jl_precompile_jobserver_destroy, Cvoid, ())
    delete!(ENV, "JULIA_PRECOMPILE_JOBSERVER")
    return nothing
end

# Bounds concurrently CPU-active workers. The `Base.Semaphore` enforces the
# process-count cap; when the jobserver is active each worker also holds one
# baseline token from the shared pool while CPU-active, balancing worker
# baselines and imaging codegen threads against one budget. A worker's main
# thread sleeps during imaging, so its baseline token doubles as that phase's
# first codegen thread.
#
# Baseline acquisition is best-effort: it proceeds tokenless when the pool is
# torn down, cancelled, or starved past a timeout (tokens leak if a worker is
# killed mid-imaging), degrading to bounded oversubscription instead of hanging.
# `ntokens` tracks tokens actually held, so releases only post back while it is
# positive and tokenless acquires can never over-post.
mutable struct WorkerLimiter
    const sem::Base.Semaphore
    const jobserver::Bool
    @atomic ntokens::Int
end
WorkerLimiter(sem::Base.Semaphore, jobserver::Bool) = WorkerLimiter(sem, jobserver, 0)

# How long an acquire keeps polling for a baseline token before proceeding
# without one. Generous enough that it is only ever hit when the pool has been
# starved abnormally (leaked tokens), not during routine imaging contention.
const JOBSERVER_BASELINE_TIMEOUT_S = 240.0

# Try to take one baseline token from the shared pool, with a yielding backoff
# so the task scheduler keeps running while the pool is drained by imaging
# workers. Returns whether a token was acquired; bails out tokenless when the
# jobserver reports inactive (torn down), `cancel` returns true, or the timeout
# expires.
function _jobserver_acquire_baseline(w::WorkerLimiter; cancel=Returns(false))
    delay = 0.005
    # time_ns is monotonic, unlike time(); UInt64 subtraction is wrap-safe
    start_ns = time_ns()
    timeout_ns = round(UInt64, JOBSERVER_BASELINE_TIMEOUT_S * 1e9)
    while true
        r = ccall(:jl_precompile_jobserver_acquire, Cint, ())
        if r == 1
            @atomic w.ntokens += 1
            return true
        end
        if r == -1 || cancel()
            return false
        end
        if (time_ns() -% start_ns) >= timeout_ns
            @debug "Precompilation jobserver baseline token unavailable for $(JOBSERVER_BASELINE_TIMEOUT_S)s; proceeding without one"
            return false
        end
        Base.sleep(delay)
        delay = min(2 * delay, 0.1)
    end
end

function _jobserver_release_baseline(w::WorkerLimiter)
    while true
        old = @atomic w.ntokens
        old == 0 && return false
        (; success) = @atomicreplace w.ntokens old => old - 1
        if success
            ccall(:jl_precompile_jobserver_release, Cvoid, ())
            return true
        end
    end
end

function Base.acquire(w::WorkerLimiter; cancel=Returns(false))
    Base.acquire(w.sem)
    if w.jobserver
        try
            _jobserver_acquire_baseline(w; cancel)
        catch
            Base.release(w.sem)
            rethrow()
        end
    end
    return nothing
end

function Base.release(w::WorkerLimiter)
    w.jobserver && _jobserver_release_baseline(w)
    Base.release(w.sem)
    return nothing
end

function Base.acquire(f, w::WorkerLimiter; cancel=Returns(false))
    Base.acquire(w; cancel)
    try
        return f()
    finally
        Base.release(w)
    end
end

## PrecompileJob

# Per-package compilation status and associated state.
@enum JobStatus::UInt8 begin
    JOB_PENDING      # not yet started
    JOB_STARTED      # compilation in progress
    JOB_RECOMPILED   # successfully compiled
    JOB_SOFT_ERROR   # compilecache returned an Exception (may work after restart)
    JOB_FAILED       # hard compile error
end

mutable struct PrecompileJob
    status::JobStatus
    started_at::Float64
    error_msg::String
    output::IOBuffer
    pid::Int32
    had_pid::Bool  # sticky: true once a subprocess was actually spawned for this job
    lock_holder::String
    waiting_for_bg::Bool
    verbose_timing::String  # raw payload from the worker subprocess; surfaced in verbose mode
    peak_rss_bytes::UInt64  # max RSS observed by `poll_process_stats!`; 0 on unsupported platforms
    PrecompileJob() = new(JOB_PENDING, 0.0, "", IOBuffer(), Int32(0), false, "", false, "", UInt64(0))
end

is_pending(j::PrecompileJob)    = j.status == JOB_PENDING
is_started(j::PrecompileJob)    = j.status == JOB_STARTED
is_recompiled(j::PrecompileJob) = j.status == JOB_RECOMPILED
is_soft_error(j::PrecompileJob) = j.status == JOB_SOFT_ERROR
is_failed(j::PrecompileJob)     = j.status == JOB_FAILED
has_pid(j::PrecompileJob)       = j.pid > 0
had_pid(j::PrecompileJob)       = j.had_pid
is_locked(j::PrecompileJob)     = !isempty(j.lock_holder)
is_waiting(j::PrecompileJob)    = j.waiting_for_bg

mark_started!(j::PrecompileJob, t::Float64=time()) = (j.status = JOB_STARTED; j.started_at = t)
mark_recompiled!(j::PrecompileJob) = (j.status = JOB_RECOMPILED)
mark_soft_error!(j::PrecompileJob) = (j.status = JOB_SOFT_ERROR)
mark_failed!(j::PrecompileJob, msg::String) = (j.status = JOB_FAILED; j.error_msg = msg)
set_pid!(j::PrecompileJob, pid::Int32) = (j.pid = pid; j.had_pid = true)
clear_pid!(j::PrecompileJob) = (j.pid = Int32(0))

function clear_failure!(j::PrecompileJob)
    j.status = JOB_PENDING
    j.error_msg = ""
    truncate(j.output, 0)
end

# A request to do precompilation work, either in a new session or merged into a running one.
struct PrecompileRequest
    pkgs::Vector{String}
    internal_call::Bool
    strict::Bool
    warn_loaded::Bool
    timing::Bool
    _from_loading::Bool
    configs::Vector{Config}
    io::IOContext
    fancyprint::Bool
    manifest::Bool
    ignore_loaded::Bool
    detachable::Bool
    result::Channel{Any}
end

# Shared mutable state for a precompilation session.
Base.@kwdef mutable struct PrecompileSession
    # Configuration (set once, read-only after construction)
    configs::Vector{Config}
    io::IOContext
    logio::IOContext
    logcalls::Union{Nothing, CoreLogging.LogLevel}
    fancyprint::Bool
    hascolor::Bool
    warn_loaded::Bool
    ignore_loaded::Bool
    internal_call::Bool
    strict::Bool
    _from_loading::Bool
    time_start::UInt64
    print_lock::ReentrantLock
    parallel_limiter::WorkerLimiter
    num_tasks::Int
    start_loaded_modules::Set{PkgId}
    requested_pkgids::Vector{PkgId}
    requested_all::Bool = false  # requested_pkgids was defaulted to the project deps

    # Dependency graph (built by setup, extended by drainer)
    direct_deps::Dict{PkgId, Vector{PkgId}}
    ext_to_parent::Dict{PkgId, PkgId}
    parent_to_exts::Dict{PkgId, Vector{PkgId}}
    triggers::Dict{PkgId, Vector{PkgId}}
    project_deps::Vector{PkgId}
    serial_deps::Vector{PkgId}
    circular_deps::Vector{PkgId}

    # Progress counters and flags
    n_done::Int                            = 0
    n_already_precomp::Int                 = 0
    n_loaded::Int                          = 0
    loaded_pkgs::Vector{PkgId}             = PkgId[]
    n_total::Int
    n_batches::Int                         = 1
    interrupted::Bool                      = false
    canceled::Bool                         = false
    interrupted_or_done::Bool              = false
    printloop_should_exit::Bool
    target::String
    pkg_liveprinted::Union{Nothing, PkgId} = nothing

    # Per-package tracking
    jobs::Dict{PkgConfig, PrecompileJob}                  = Dict{PkgConfig,PrecompileJob}()
    was_processed::Dict{PkgConfig, Base.Event}
    stale_cache::Dict{StaleCacheKey, Bool}         = Dict{StaleCacheKey,Bool}()
    cachepath_cache::Dict{PkgId, Vector{String}}   = Dict{PkgId,Vector{String}}()
    pkg_queue::Vector{PkgConfig}                   = PkgConfig[]
    prev_cpu_times::Dict{Int32, UInt64}            = Dict{Int32,UInt64}()

    # Synchronization
    first_started::Base.Event                      = Base.Event()
    cache_lock::ReentrantLock                      = ReentrantLock()

    # Task tracking
    tasks::Vector{Task}                            = Task[]
    injected_tasks::Vector{Task}                   = Task[]
    result_waiters::Vector{Task}                   = Task[]
end

# Background precompilation state
#
# Lock ordering (outermost first): BG.lock → BG.pkg_done → BG.task_done
# All three have independent ReentrantLocks. Always acquire in this order to avoid deadlocks.
mutable struct BackgroundPrecompileState
    task::Union{Nothing, Task}
    interrupt_requested::Bool
    cancel_requested::Bool
    monitoring::Bool
    completed_at::Union{Nothing, Float64}
    result::Union{Nothing, String}
    return_value::Any
    exception::Any
    lock::ReentrantLock
    task_done::Threads.Condition
    signal_channels::Vector{Channel{Int32}}  # channels for broadcasting signals to all subprocesses
    pending_pkgids::Dict{PkgId, Int}  # packages queued and currently being precompiled (refcount for multi-config)
    completed_pkgids::Set{PkgId}  # packages that have finished precompiling (for fast-finish detection)
    pkg_done::Threads.Condition  # notified when a package finishes precompiling
    work_channel::Channel{PrecompileRequest}  # channel for injecting requests into running task
    verbose::Bool  # show PIDs and CPU% for each worker
    detachable::Bool  # whether the monitor can be detached
    confirming::Symbol  # :none, :cancel, or :info — action awaiting Enter to confirm
    confirm_deadline::Float64  # time() deadline for confirmation
    info_requested::Bool  # whether SIGINFO/SIGUSR1 has been broadcast at least once
    key_listening::Bool  # whether a key listener task is currently consuming stdin
end
Base.lock(f, bg::BackgroundPrecompileState) = lock(f, bg.lock)
Base.lock(bg::BackgroundPrecompileState) = lock(bg.lock)
Base.unlock(bg::BackgroundPrecompileState) = unlock(bg.lock)

const BG = BackgroundPrecompileState(nothing, false, false, false, nothing, nothing, nothing, nothing, ReentrantLock(), Threads.Condition(), Channel{Int32}[], Dict{PkgId, Int}(), Set{PkgId}(), Threads.Condition(), Channel{PrecompileRequest}(Inf), false, false, :none, 0.0, false, false)

# Serializes the inject-vs-launch decision in `_precompilepkgs` with the launch
# itself. Lock ordering: acquired before (outside) BG.lock, never while holding it.
const launch_lock = ReentrantLock()

## Constants and formatting utilities

const ansi_movecol1 = "\e[1G"
const ansi_cleartoend = "\e[0J"
const ansi_cleartoendofline = "\e[0K"
const ansi_enablecursor = "\e[?25h"
const ansi_disablecursor = "\e[?25l"
const ansi_clearline = "\e[2K"
ansi_moveup(n::Int) = string("\e[", n, "A")

struct PkgPrecompileError <: Exception
    msg::String
end
Base.showerror(io::IO, err::PkgPrecompileError) = print(io, err.msg)
Base.showerror(io::IO, err::PkgPrecompileError, bt; kw...) = Base.showerror(io, err) # hide stacktrace

# This needs a show method to make `julia> err` show nicely
Base.show(io::IO, err::PkgPrecompileError) = print(io, "PkgPrecompileError: ", err.msg)

can_fancyprint(io::IO) = @something(get(io, :force_fancyprint, nothing), (io isa Base.TTY && (get(ENV, "CI", nothing) != "true")))

function printpkgstyle(io, header, msg; color=:green)
    return @lock io begin
        printstyled(io, header; color, bold=true)
        println(io, " ", msg)
    end
end

timing_string(t) = string(lpad(round(t, digits = 1), 6), " s")

# Parse the marker payload emitted by `Base.include_package_for_output` and
# format an inline breakdown (include / compilation / image-gen seconds,
# cache file size and cached method count) appended to the per-package timing
# line in verbose mode. Column labels are emitted once via
# `format_verbose_timing_header`. Returns an empty string if the payload is
# missing or unparsable.
function format_verbose_timing(payload::AbstractString, total_seconds::Float64, cache_bytes::Int, peak_rss_bytes::UInt64, hascolor::Bool)
    isempty(payload) && return ""
    include_ns = compilation_ns = deps_ns = UInt64(0)
    methods = 0
    seen = false
    for tok in split(payload)
        m = match(r"^(include|compilation|deps)_ns=(\d+)$", tok)
        if m !== nothing
            v = parse(UInt64, m.captures[2])
            tag = m.captures[1]
            if tag == "include"
                include_ns = v; seen = true
            elseif tag == "compilation"
                compilation_ns = v
            else
                deps_ns = v
            end
            continue
        end
        m = match(r"^methods=(\d+)$", tok)
        m === nothing || (methods = parse(Int, m.captures[1]))
    end
    seen || return ""
    inc_s = include_ns / 1e9
    comp_s = compilation_ns / 1e9
    deps_s = deps_ns / 1e9
    img_s = max(total_seconds - inc_s, 0.0)
    dim(s, isz) = isz ? color_string(s, :light_black, hascolor) : s
    function col(x)
        s = string(round(x, digits = 2))
        dot = findfirst('.', s)
        if dot === nothing
            s *= ".00"
        elseif length(s) - dot == 1
            s *= "0"
        end
        dim(string(lpad(s, 6), "s"), x < 0.005)
    end
    cache_mb = cache_bytes / 1024^2
    cache_str = cache_bytes <= 0 ? dim(lpad("-", 8), true) :
                                   string(lpad(round(cache_mb, digits = 1), 6), "MB")
    peak_mb = peak_rss_bytes / 1024^2
    peak_str = peak_rss_bytes == 0 ? dim(lpad("-", 7), true) :
                                     string(lpad(round(Int, peak_mb), 5), "MB")
    methods_str = dim(lpad(methods, 7), methods == 0)
    bar = " │ "
    return string(bar, col(inc_s), "  ", col(deps_s), "  ", col(comp_s),
                  bar, methods_str, "  ", col(img_s), "  ", cache_str, "  ", peak_str)
end

# Header that labels the columns produced by `format_verbose_timing`. The leading
# 8 spaces account for `timing_string`'s width so columns line up.
format_verbose_timing_header() = "   total │ include   (deps)   (comp) │ methods  img-gen     cache  ~pk-rss"

# Sum the on-disk size of the `.ji` cache file and (optionally) its companion
# pkgimage (`.so`/`.dylib`/`.dll`). Returns 0 if files are missing.
function _precompile_cache_bytes(cf_jl::AbstractString, cf_so::Union{Nothing,AbstractString})
    total = 0
    try
        isfile(cf_jl) && (total += filesize(cf_jl))
        cf_so === nothing || (isfile(cf_so) && (total += filesize(cf_so)))
    catch
    end
    return total
end



function color_string(cstr::String, col::Union{Int64, Symbol}, hascolor)
    if hascolor
        enable_ansi  = get(Base.text_colors, col, Base.text_colors[:default])
        disable_ansi = get(Base.disable_text_style, col, Base.text_colors[:default])
        return string(enable_ansi, cstr, disable_ansi)
    else
        return cstr
    end
end

# using Printf
Base.@kwdef mutable struct MiniProgressBar
    max::Int = 1
    header::String = ""
    color::Symbol = :nothing
    width::Int = 40
    current::Int = 0
    prev::Int = 0
    has_shown::Bool = false
    time_shown::Float64 = 0.0
    percentage::Bool = true
    always_reprint::Bool = false
    indent::Int = 4
end

const PROGRESS_BAR_TIME_GRANULARITY = Ref(1 / 30.0) # 30 fps
const PROGRESS_BAR_PERCENTAGE_GRANULARITY = Ref(0.1)

start_progress(io::IO, _::MiniProgressBar) = print(io, ansi_disablecursor)

function show_progress(io::IO, p::MiniProgressBar; termwidth=nothing, carriagereturn=true)
    if p.max == 0
        perc = 0.0
        prev_perc = 0.0
    else
        perc = p.current / p.max * 100
        prev_perc = p.prev / p.max * 100
    end
    # Bail early if we are not updating the progress bar,
    # Saves printing to the terminal
    if !p.always_reprint && p.has_shown && !((perc - prev_perc) > PROGRESS_BAR_PERCENTAGE_GRANULARITY[])
        return
    end
    t = time()
    if !p.always_reprint && p.has_shown && (t - p.time_shown) < PROGRESS_BAR_TIME_GRANULARITY[]
        return
    end
    p.time_shown = t
    p.prev = p.current
    p.has_shown = true

    progress_text = string(p.current, "/",  p.max)
    termwidth = @something termwidth (displaysize(io)::Tuple{Int,Int})[2]
    max_progress_width = max(0, min(termwidth - textwidth(p.header) - textwidth(progress_text) - 10 , p.width))
    filled = max_progress_width * clamp(perc / 100, 0.0, 1.0)
    (partial_filled, n_filled::Int64) = modf(filled) # get fractional / integer part
    n_left = max_progress_width - n_filled
    headers = split(p.header, ' ')
    to_print = sprint(; context=io) do io
        print(io, " "^p.indent)
        printstyled(io, headers[1], " "; color=:green, bold=true)
        printstyled(io, join(headers[2:end], ' '))
        print(io, " ")
        printstyled(io, "━"^n_filled; color=p.color)
        if n_left > 0
            if partial_filled > 0.5
                printstyled(io, "╸"; color=p.color) # More filled, use ╸
            else
                printstyled(io, "╺"; color=:light_black) # Less filled, use ╺
            end
            printstyled(io, "━"^(n_left-1); color=:light_black)
        end
        printstyled(io, " "; color=:light_black)
        print(io, progress_text)
        carriagereturn && print(io, "\r")
    end
    # Print everything in one call
    print(io, to_print)
end

end_progress(io, p::MiniProgressBar) = print(io, ansi_enablecursor, ansi_clearline)

print_progress_bottom(io::IO) = print(io, "\e[S", ansi_moveup(1), ansi_clearline, ansi_movecol1)

## ExplicitEnv

# This is currently only used for pkgprecompile but the plan is to use this in code loading in the future
# see the `kc/codeloading2.0` branch
struct ExplicitEnv
    path::String
    project_deps::Dict{String, UUID}     # [deps] in the active project's Project.toml
    project_weakdeps::Dict{String, UUID} # [weakdeps] in the active project's Project.toml
    project_extras::Dict{String, UUID}   # [extras] in the active project's Project.toml
    project_extensions::Dict{String, Vector{UUID}} # [extensions] in the active project's Project.toml
    workspace_deps::Dict{String, UUID}   # union of [deps] from all workspace member Project.tomls
    deps::Dict{UUID, Vector{UUID}}       # full dependency graph from Manifest.toml
    weakdeps::Dict{UUID, Vector{UUID}}   # full weak dependency graph from Manifest.toml
    extensions::Dict{UUID, Dict{String, Vector{UUID}}}
    # Lookup name for a UUID
    names::Dict{UUID, String}
    lookup_strategy::Dict{UUID, Union{
                                      SHA1,     # `git-tree-sha1` entry
                                      String,   # `path` entry
                                      Nothing,  # stdlib (no `path` nor `git-tree-sha1`)
                                      Missing}} # not present in the manifest
    #prefs::Union{Nothing, Dict{String, Any}}
    #local_prefs::Union{Nothing, Dict{String, Any}}
end

ExplicitEnv() = ExplicitEnv(Base.active_project())
function ExplicitEnv(::Nothing, envpath::String="")
    ExplicitEnv(envpath,
        Dict{String, UUID}(),     # project_deps
        Dict{String, UUID}(),     # project_weakdeps
        Dict{String, UUID}(),     # project_extras
        Dict{String, Vector{UUID}}(), # project_extensions
        Dict{String, UUID}(),     # workspace_deps
        Dict{UUID, Vector{UUID}}(),   # deps
        Dict{UUID, Vector{UUID}}(),   # weakdeps
        Dict{UUID, Dict{String, Vector{UUID}}}(), # extensions
        Dict{UUID, String}(),     # names
        Dict{UUID, Union{SHA1, String, Nothing, Missing}}())
end
function ExplicitEnv(envpath::String)
    # Handle missing project file by creating an empty environment
    if !isfile(envpath) || project_file_manifest_path(envpath) === nothing
        envpath = abspath(envpath)
        return ExplicitEnv(nothing, envpath)
    end
    envpath = abspath(envpath)
    project_d = parsed_toml(envpath)

    # TODO: Perhaps verify that two packages with the same UUID do not have different names?
    names = Dict{UUID, String}()
    project_name_to_uuid = Dict{String, UUID}()

    project_deps = Dict{String, UUID}()
    project_weakdeps = Dict{String, UUID}()
    project_extras = Dict{String, UUID}()

    # Collect all direct dependencies of the project
    for key in ["deps", "weakdeps", "extras"]
        for (name, _uuid) in get(Dict{String, Any}, project_d, key)::Dict{String, Any}
            v = key == "deps" ? project_deps :
                key == "weakdeps" ? project_weakdeps :
                key == "extras" ? project_extras :
                error()
            uuid = UUID(_uuid::String)
            v[name] = uuid
            names[uuid] = name
            project_name_to_uuid[name] = uuid
        end
    end

    # A package in both deps and weakdeps is in fact only a weakdep
    for (name, _) in project_weakdeps
        delete!(project_deps, name)
    end

    # This project might be a package, in that case, that is also a "dependency"
    # of the project.
    proj_name = get(project_d, "name", nothing)::Union{String, Nothing}
    _proj_uuid = get(project_d, "uuid", nothing)::Union{String, Nothing}
    proj_uuid = _proj_uuid === nothing ? nothing : UUID(_proj_uuid)

    project_is_package = proj_name !== nothing && proj_uuid !== nothing
    if project_is_package
        # TODO: Error on missing uuid?
        project_deps[proj_name] = proj_uuid
        names[proj_uuid] = proj_name
    end

    project_extensions = Dict{String, Vector{UUID}}()
    # Collect all extensions of the project
    for (name, triggers) in get(Dict{String, Any}, project_d, "extensions")::Dict{String, Any}
        if triggers isa String
            triggers = [triggers]
        else
            triggers = triggers::Vector{String}
        end
        uuids = UUID[]
        for trigger in triggers
            uuid = get(project_name_to_uuid, trigger, nothing)
            if uuid === nothing
                error("Trigger $trigger for extension $name not found in project")
            end
            push!(uuids, uuid)
        end
        project_extensions[name] = uuids
    end

    manifest = project_file_manifest_path(envpath)
    manifest_d = manifest === nothing ? Dict{String, Any}() : parsed_toml(manifest)

    # Dependencies in a manifest can either be stored compressed (when name is unique among all packages)
    # in which case it is a `Vector{String}` or expanded where it is a `name => uuid` mapping.
    deps = Dict{UUID, Union{Vector{String}, Vector{UUID}}}()
    weakdeps = Dict{UUID, Union{Vector{String}, Vector{UUID}}}()
    extensions = Dict{UUID, Dict{String, Vector{String}}}()
    name_to_uuid = Dict{String, UUID}()
    lookup_strategy = Dict{UUID, Union{SHA1, String, Nothing, Missing}}()

    sizehint!(deps, length(manifest_d))
    sizehint!(weakdeps, length(manifest_d))
    sizehint!(extensions, length(manifest_d))
    sizehint!(name_to_uuid, length(manifest_d))
    sizehint!(lookup_strategy, length(manifest_d))

    for (name, pkg_infos) in get_deps(manifest_d)
        for pkg_info in pkg_infos::Vector{Any}
            pkg_info = pkg_info::Dict{String, Any}
            m_uuid = UUID(pkg_info["uuid"]::String)

            # If we have multiple packages with the same name we will overwrite things here
            # but that is fine since we will only use the information in here for packages
            # with unique names
            names[m_uuid] = name
            name_to_uuid[name] = m_uuid

            for key in ["deps", "weakdeps"]
                deps_pkg = get(Vector{String}, pkg_info, key)::Union{Vector{String}, Dict{String, Any}}
                d = key == "deps" ? deps :
                    key == "weakdeps" ? weakdeps :
                    error()

                # Compressed format with unique names:
                if deps_pkg isa Vector{String}
                    d[m_uuid] = deps_pkg
                # Expanded format:
                else
                    uuids = UUID[]
                    for (name_dep, _dep_uuid) in deps_pkg
                        dep_uuid = UUID(_dep_uuid::String)
                        push!(uuids, dep_uuid)
                        names[dep_uuid] = name_dep
                    end
                    d[m_uuid] = uuids
                end
            end

            # Extensions
            deps_pkg = get(Dict{String, Any}, pkg_info, "extensions")::Dict{String, Any}
            deps_pkg_concrete = Dict{String, Vector{String}}()
            for (ext, triggers) in deps_pkg
                if triggers isa String
                    triggers = [triggers]
                else
                    triggers = triggers::Vector{String}
                end
                deps_pkg_concrete[ext] = triggers
            end
            extensions[m_uuid] = deps_pkg_concrete

            # Determine strategy to find package
            lookup_strat = begin
                if (path = get(pkg_info, "path", nothing)::Union{String, Nothing}) !== nothing
                    path
                elseif (git_tree_sha_str = get(pkg_info, "git-tree-sha1", nothing)::Union{String, Nothing}) !== nothing
                    SHA1(git_tree_sha_str)
                else
                    nothing
                end
            end
            lookup_strategy[m_uuid] = lookup_strat
        end
    end

    # No matter if the deps were stored compressed or not in the manifest,
    # we internally store them expanded
    deps_expanded = Dict{UUID, Vector{UUID}}()
    weakdeps_expanded = Dict{UUID, Vector{UUID}}()
    extensions_expanded = Dict{UUID, Dict{String, Vector{UUID}}}()
    sizehint!(deps_expanded, length(deps))
    sizehint!(weakdeps_expanded, length(deps))
    sizehint!(extensions_expanded, length(deps))

    if proj_name !== nothing && proj_uuid !== nothing
        deps_expanded[proj_uuid] = filter!(!=(proj_uuid), collect(values(project_deps)))
        extensions_expanded[proj_uuid] = project_extensions
        path = get(project_d, "path", nothing)::Union{String, Nothing}
        entry_point = path !== nothing ? path : dirname(envpath)
        lookup_strategy[proj_uuid] = entry_point
    end

    for key in ["deps", "weakdeps"]
        d = key == "deps" ? deps :
            key == "weakdeps" ? weakdeps :
            error()
        d_expanded = key == "deps" ? deps_expanded :
                     key == "weakdeps" ? weakdeps_expanded :
                     error()
        for (pkg, deps) in d
            # dependencies was already expanded so use it directly:
            if deps isa Vector{UUID}
                d_expanded[pkg] = deps
                for dep in deps
                    name_to_uuid[names[dep]] = dep
                end
            # find the (unique) UUID associated with the name
            else
                deps_pkg = UUID[]
                sizehint!(deps_pkg, length(deps))
                for dep in deps
                    push!(deps_pkg, name_to_uuid[dep])
                end
                d_expanded[pkg] = deps_pkg
            end
        end
    end

    for (pkg, exts) in extensions
        exts_expanded = Dict{String, Vector{UUID}}()
        for (ext, triggers) in exts
            triggers_expanded = UUID[]
            sizehint!(triggers_expanded, length(triggers))
            for trigger in triggers
                push!(triggers_expanded, name_to_uuid[trigger])
            end
            exts_expanded[ext] = triggers_expanded
        end
        extensions_expanded[pkg] = exts_expanded
    end

    # Everything that does not yet have a lookup_strategy is missing from the manifest
    for (_, uuid) in project_deps
        get!(lookup_strategy, uuid, missing)
    end

    #=
    # Preferences:
    prefs = get(project_d, "preferences", nothing)

    # `(Julia)LocalPreferences.toml`
    project_dir = dirname(envpath)
    local_prefs = nothing
    for name in preferences_names
        toml_path = joinpath(project_dir, name)
        if isfile(toml_path)
            local_prefs = parsed_toml(toml_path)
            break
        end
    end
    =#

    # Collect the union of [deps] from all workspace member projects.
    # For non-workspace projects, this is the same as project_deps.
    workspace_deps = copy(project_deps)
    base = base_project(envpath)
    if base !== nothing
        base_d = parsed_toml(base)
        # Add deps from the workspace root project
        for (name, _uuid) in get(Dict{String, Any}, base_d, "deps")::Dict{String, Any}
            workspace_deps[name] = UUID(_uuid::String)
        end
        # Add deps from each workspace member project
        ws = get(base_d, "workspace", nothing)::Union{Dict{String, Any}, Nothing}
        if ws !== nothing
            ws_projects = get(ws, "projects", nothing)::Union{Vector{String}, Nothing, String}
            if ws_projects isa Vector
                ws_root = dirname(base)
                for ws_proj in ws_projects
                    ws_proj_dir = joinpath(ws_root, ws_proj)
                    ws_proj_file = Base.env_project_file(ws_proj_dir)
                    ws_proj_file isa String || continue
                    ws_d = parsed_toml(ws_proj_file)
                    for (name, _uuid) in get(Dict{String, Any}, ws_d, "deps")::Dict{String, Any}
                        workspace_deps[name] = UUID(_uuid::String)
                    end
                end
            end
        end
    end

    return ExplicitEnv(envpath, project_deps, project_weakdeps, project_extras,
                       project_extensions, workspace_deps,
                       deps_expanded, weakdeps_expanded, extensions_expanded,
                       names, lookup_strategy, #=prefs, local_prefs=#)
end

## Dependency graph

# name or parent → ext
function full_name(ext_to_parent::Dict{PkgId, PkgId}, pkg::PkgId)
    if haskey(ext_to_parent, pkg)
        return string(ext_to_parent[pkg].name, " → ", pkg.name)
    else
        return pkg.name
    end
end

function excluded_circular_deps_explanation(io::IOContext, ext_to_parent::Dict{PkgId, PkgId}, circular_deps, cycles)
    outer_deps = copy(circular_deps)
    cycles_names = ""
    hascolor = get(io, :color, false)::Bool
    for cycle in cycles
        filter!(!in(cycle), outer_deps)
        cycle_str = ""
        for (i, pkg) in enumerate(cycle)
            j = max(0, i - 1)
            if length(cycle) == 1
                line = " ─ "
            elseif i == 1
                line = " ┌ "
            elseif i < length(cycle)
                line = " │ " * " " ^j
            else
                line = " └" * "─" ^j * " "
            end
            line = color_string(line, :light_black, hascolor) * full_name(ext_to_parent, pkg) * "\n"
            cycle_str *= line
        end
        cycles_names *= cycle_str
    end
    plural1 = length(cycles) > 1 ? "these cycles" : "this cycle"
    plural2 = length(cycles) > 1 ? "cycles" : "cycle"
    msg = """Circular dependency detected.
    Precompilation will be skipped for dependencies in $plural1:
    $cycles_names"""
    if !isempty(outer_deps)
        msg *= "Precompilation will also be skipped for the following, which depend on the above $plural2:\n"
        msg *= join(("  " * full_name(ext_to_parent, pkg) for pkg in outer_deps), "\n")
    end
    return msg
end


function scan_pkg!(stack, could_be_cycle, cycles, pkg, dmap)
    if haskey(could_be_cycle, pkg)
        return could_be_cycle[pkg]
    else
        return scan_deps!(stack, could_be_cycle, cycles, pkg, dmap)
    end
end
function scan_deps!(stack, could_be_cycle, cycles, pkg, dmap)
    push!(stack, pkg)
    cycle = nothing
    for dep in dmap[pkg]
        if dep in stack
            # Created fresh cycle
            cycle′ = stack[findlast(==(dep), stack):end]
            if cycle === nothing || length(cycle′) < length(cycle)
                cycle = cycle′ # try to report smallest cycle possible
            end
        elseif scan_pkg!(stack, could_be_cycle, cycles, dep, dmap)
            # Reaches an existing cycle
            could_be_cycle[pkg] = true
            pop!(stack)
            return true
        end
    end
    pop!(stack)
    if cycle !== nothing
        push!(cycles, cycle)
        could_be_cycle[pkg] = true
        return true
    end
    could_be_cycle[pkg] = false
    return false
end

# restrict to dependencies of given packages
function collect_all_deps(direct_deps, dep, alldeps=Set{Base.PkgId}())
    for _dep in direct_deps[dep]
        if !(_dep in alldeps)
            push!(alldeps, _dep)
            collect_all_deps(direct_deps, _dep, alldeps)
        end
    end
    return alldeps
end

function visit_indirect_deps!(direct_deps::Dict{PkgId, Vector{PkgId}}, visited::Set{PkgId},
                               node::PkgId, all_deps::Set{PkgId})
    if node in visited
        return
    end
    push!(visited, node)
    for dep in get(Set{PkgId}, direct_deps, node)
        if !(dep in all_deps)
            push!(all_deps, dep)
            visit_indirect_deps!(direct_deps, visited, dep, all_deps)
        end
    end
    return
end

# Build dependency graph from an ExplicitEnv.
# Returns a NamedTuple of (direct_deps, ext_to_parent, parent_to_exts, triggers, project_deps, serial_deps).
function build_dep_graph(env::ExplicitEnv, manifest::Bool, _from_loading::Bool, requested_pkgids::Vector{PkgId})
    direct_deps = Dict{PkgId, Vector{PkgId}}()
    parent_to_exts = Dict{PkgId, Vector{PkgId}}()
    ext_to_parent = Dict{PkgId, PkgId}()
    triggers = Dict{PkgId, Vector{PkgId}}()

    # Determine which packages to consider for precompilation by walking
    # transitive dependencies from the appropriate roots.
    # `manifest` controls the scope: workspace_deps (all members) vs project_deps (current project).
    roots = manifest ? env.workspace_deps : env.project_deps
    pkg_uuids = Set{UUID}()
    for (_, uuid) in roots
        _collect_reachable!(pkg_uuids, env.deps, uuid)
    end

    for dep in pkg_uuids
        haskey(env.deps, dep) || continue
        pkg = PkgId(dep, env.names[dep])
        Base.in_sysimage(pkg) && continue
        deps = [PkgId(x, env.names[x]) for x in env.deps[dep]]
        direct_deps[pkg] = filter!(!Base.in_sysimage, deps)
        for (ext_name, trigger_uuids) in get(Dict{String, Vector{UUID}}, env.extensions, dep)
            ext_uuid = Base.uuid5(pkg.uuid, ext_name)
            ext = PkgId(ext_uuid, ext_name)
            triggers[ext] = PkgId[pkg]
            all_triggers_available = true
            for trigger_uuid in trigger_uuids
                trigger_name = PkgId(trigger_uuid, env.names[trigger_uuid])
                if trigger_uuid in pkg_uuids || Base.in_sysimage(trigger_name)
                    push!(triggers[ext], trigger_name)
                else
                    all_triggers_available = false
                    break
                end
            end
            all_triggers_available || continue
            ext_to_parent[ext] = pkg
            direct_deps[ext] = filter(!Base.in_sysimage, triggers[ext])
            if !haskey(parent_to_exts, pkg)
                parent_to_exts[pkg] = PkgId[ext]
            else
                push!(parent_to_exts[pkg], ext)
            end
        end
    end

    project_deps = [
        PkgId(uuid, name)
        for (name, uuid) in env.project_deps if !Base.in_sysimage(PkgId(uuid, name))
    ]
    # consider exts of project deps to be project deps so that errors are reported
    append!(project_deps, keys(filter(d->last(d).name in keys(env.project_deps), ext_to_parent)))

    # An extension effectively depends on another extension if it has a strict superset of its triggers
    for ext_a in keys(ext_to_parent)
        for ext_b in keys(ext_to_parent)
            if triggers[ext_a] ⊋ triggers[ext_b]
                push!(triggers[ext_a], ext_b)
                push!(direct_deps[ext_a], ext_b)
            end
        end
    end

    # A package depends on an extension if it (indirectly) depends on all extension triggers
    # Iterate to a fixed point because adding an extension edge (e.g. ExtA → TopPkg)
    # may cause another extension (e.g. ExtAB, which depends on ExtA) to become
    # loadable in TopPkg on the next iteration.
    changed = true
    while changed
        changed = false
        indirect_deps = Dict{PkgId, Set{PkgId}}()
        for package in keys(direct_deps)
            all_deps = Set{PkgId}()
            visited = Set{PkgId}()
            visit_indirect_deps!(direct_deps, visited, package, all_deps)
            indirect_deps[package] = all_deps
        end
        for ext in keys(ext_to_parent)
            ext_loadable_in_pkg = Dict{PkgId,Bool}()
            for pkg in keys(direct_deps)
                is_trigger = in(pkg, direct_deps[ext])
                is_extension = in(pkg, keys(ext_to_parent))
                has_triggers = issubset(direct_deps[ext], indirect_deps[pkg])
                ext_loadable_in_pkg[pkg] = !is_extension && has_triggers && !is_trigger
            end
            for (pkg, ext_loadable) in ext_loadable_in_pkg
                if ext_loadable && !any((dep)->ext_loadable_in_pkg[dep], direct_deps[pkg])
                    if ext ∉ direct_deps[pkg]
                        # add an edge if the extension is loadable by pkg, and was not loadable in any
                        # of the pkg's dependencies
                        push!(direct_deps[pkg], ext)
                        changed = true
                    end
                end
            end
        end
    end

    serial_deps = PkgId[]
    if _from_loading
        for pkgid in requested_pkgids
            pkgid === nothing && continue
            if !haskey(direct_deps, pkgid)
                @debug "precompile: package `$(pkgid)` is outside of the environment, so adding as single package serial job"
                direct_deps[pkgid] = PkgId[]
                push!(project_deps, pkgid)
                push!(serial_deps, pkgid)
            end
        end
    end

    return (; direct_deps, ext_to_parent, parent_to_exts, triggers, project_deps, serial_deps)
end

# Detect circular dependencies and notify their Events so waiting tasks can skip them.
# Returns the list of packages involved in or dependent on cycles.
function detect_circular_deps!(direct_deps, serial_deps, was_processed, io, ext_to_parent)
    cycles = Vector{PkgId}[]
    could_be_cycle = Dict{PkgId, Bool}()
    stack = PkgId[]
    circular_deps = PkgId[]
    for pkg in keys(direct_deps)
        @assert isempty(stack)
        pkg in serial_deps && continue
        if scan_pkg!(stack, could_be_cycle, cycles, pkg, direct_deps)
            push!(circular_deps, pkg)
            for (pkg_config, evt) in was_processed
                pkg_config[1] == pkg && notify(evt)
            end
        end
    end
    if !isempty(circular_deps)
        printpkgstyle(io, :Warning, excluded_circular_deps_explanation(io, ext_to_parent, circular_deps, cycles), color=Base.warn_color())
    end
    return circular_deps
end

# Filter the dependency graph to only include requested packages and their transitive deps.
# Returns true if the graph became empty (caller should return early).
function filter_dep_graph!(direct_deps, pkg_names, ext_to_parent, requested_pkgids)
    isempty(pkg_names) && return false
    keep = Set{PkgId}()
    for dep_pkgid in keys(direct_deps)
        if dep_pkgid.name in pkg_names
            push!(keep, dep_pkgid)
            collect_all_deps(direct_deps, dep_pkgid, keep)
        end
    end
    for requested_pkgid in requested_pkgids
        if haskey(direct_deps, requested_pkgid)
            push!(keep, requested_pkgid)
            collect_all_deps(direct_deps, requested_pkgid, keep)
        end
    end
    for ext in keys(ext_to_parent)
        if issubset(collect_all_deps(direct_deps, ext), keep)
            push!(keep, ext)
        end
    end
    filter!(d->in(first(d), keep), direct_deps)
    return isempty(direct_deps)
end

## Public API

"""
    precompilepkgs(pkgs; kwargs...)

Precompile packages and their dependencies, with support for parallel compilation,
progress tracking, and various compilation configurations.

`pkgs::Union{Vector{String}, Vector{PkgId}}`: Packages to precompile. When
empty (default), precompiles all project dependencies. When specified,
precompiles only the given packages and their dependencies (unless
`manifest=true`).

!!! note
    Errors will only throw when precompiling the top-level dependencies, given that
    not all manifest dependencies may be loaded by the top-level dependencies on the given system.
    This can be overridden to make errors in all dependencies throw by setting the kwarg `strict` to `true`

# Keyword Arguments
- `internal_call::Bool`: Indicates this is an automatic precompilation call
  from somewhere external (e.g. Pkg). Do not use this parameter.

- `strict::Bool`: Controls error reporting scope. When `false` (default), only reports
  errors for direct project dependencies. Only relevant when `manifest=true`.

- `warn_loaded::Bool`: When `true` (default), checks for and warns about packages that are
  precompiled but already loaded with a different version. Displays a warning that Julia
  needs to be restarted to use the newly precompiled versions.

- `timing::Bool`: When `true` (not default), displays timing information for
  each package compilation, but only if compilation might have succeeded.
  Disables fancy progress bar output (timing is shown in simple text mode).

- `verbose::Bool`: When `true` (not default), enables verbose timing mode: implies
  `timing=true` and appends a per-package breakdown to each completion line with
  these columns:
    * `total`   — total wall-clock time for the worker subprocess.
    * `include` — time spent in `Base.include` of the package source.
    * `(deps)`  — subset of `include`: time spent loading already-precompiled
                  dependencies from disk (via `require`).
    * `(comp)`  — subset of `include`: cumulative compile time (type inference
                  and code generation) reported by `cumulative_compile_time_ns`.
    * `methods` — number of newly-inferred methods cached for this package.
    * `img-gen` — `total - include`: post-include work (native-code generation,
                  serialization, writing the `.ji` and pkgimage to disk).
    * `cache`   — combined on-disk size of the `.ji` cache and any pkgimage.
    * `~pk-rss` — approximate peak resident set size of the worker subprocess.
                  The leading `~` indicates the value is sampled (every ~0.5 s)
                  rather than observed continuously, so transient peaks between
                  samples may be missed. Linux/macOS only, `-` elsewhere.
  Values under 5 ms and zero counts are dimmed for readability.

- `_from_loading::Bool`: Internal flag indicating the call originated from the
  package loading system. When `true` (not default): returns early instead of
  throwing when packages are not found; suppresses progress messages when not
  in an interactive session; allows packages outside the current environment to
  be added as serial precompilation jobs; skips LOADING_CACHE initialization;
  and changes cachefile locking behavior.

- `configs::Union{Config,Vector{Config}}`: Compilation configurations to use. Each Config
  is a `Pair{Cmd, Base.CacheFlags}` specifying command flags and cache flags. When
  multiple configs are provided, each package is precompiled for each configuration.

- `io::IO`: Output stream for progress messages, warnings, and errors. Can be
  redirected (e.g., to `devnull` when called from loading in non-interactive mode).

- `fancyprint::Bool`: Controls output format. When `true`, displays an animated progress
  bar with spinners. When `false`, instead enables `timing` mode. Automatically
  disabled when `timing=true` or when called from loading in non-interactive mode.

- `manifest::Bool`: Controls the scope of packages to precompile. When `false` (default),
  precompiles only packages specified in `pkgs` and their dependencies. When `true`,
  precompiles all packages in the manifest (workspace mode), typically used by Pkg for
  workspace precompile requests.

- `ignore_loaded::Bool`: Controls whether already-loaded packages affect cache
  freshness checks. When `false` (not default), loaded package versions are considered when
  determining if cache files are fresh.

- `detachable::Bool`: When `true` (not default), allows detaching from the
  precompilation monitor with the `d` key, letting precompilation continue in
  the background. The monitor can be reattached later via
  [`Base.Precompilation.monitor_background_precompile`](@ref). Pkg.jl passes
  `detachable=true` in interactive sessions.

# Keyboard Controls

When running interactively in a TTY, the following keys are available during
precompilation:

  - **`c`** — Cancel precompilation. Prompts for Enter to confirm; ignored after
    5 seconds or if any other key is pressed.
  - **`d`/`q`/`]`** — Detach (only when `detachable=true`). Returns to the REPL while
    precompilation continues in the background.
  - **`i`** — Info. Sends a profiling signal (SIGINFO on macOS/BSD, SIGUSR1 on
    Linux) to subprocesses, triggering a profile peek without interrupting
    compilation. Prompts for Enter to confirm; ignored after 5 seconds or if any
    other key is pressed.
  - **`v`** — Toggle verbose mode. Shows elapsed time and worker PID for each actively
    compiling package, plus CPU% and memory (RSS) on Linux and macOS.
  - **`?`/`h`** — Show keyboard shortcut help.
  - **Ctrl-C** — Interrupt. Sends SIGINT to subprocesses and displays their output.

# Return
- `Vector{String}`: Paths to cache files for the requested packages.
- `Nothing`: precompilation should be skipped

# Notes
- Packages in circular dependency cycles are skipped with a warning.
- Packages with `__precompile__(false)` are skipped if they are from loading to
  avoid repeated work on every session.
- Parallel compilation is controlled by `JULIA_NUM_PRECOMPILE_TASKS` environment variable
  (defaults to CPU_THREADS + 1, capped at 16, halved on Windows). The total CPU-thread
  budget shared across those workers (worker baselines plus native-image codegen threads)
  is controlled by `JULIA_PRECOMPILE_THREADS` (defaults to CPU_THREADS + 1).
- Extensions are precompiled when all their triggers are available in the environment.
"""
function precompilepkgs(pkgs::Union{Vector{String}, Vector{PkgId}}=String[];
                        internal_call::Bool=false,
                        strict::Bool = false,
                        warn_loaded::Bool = true,
                        timing::Bool = false,
                        verbose::Bool = false,
                        _from_loading::Bool=false,
                        configs::Union{Config,Vector{Config}}=(``=>Base.CacheFlags()),
                        io::IO=stderr,
                        # asking for timing disables fancy mode, as timing is shown in non-fancy mode;
                        # verbose implies timing (see below), so also disables fancy mode
                        fancyprint::Bool = can_fancyprint(io) && !timing && !verbose,
                        manifest::Bool=false,
                        ignore_loaded::Bool=true,
                        detachable::Bool=false)
    # verbose timing mode requires timing to be enabled (per-package breakdown
    # is only shown alongside timing lines in non-fancy mode)
    verbose && (timing = true)
    @debug "precompilepkgs called with" pkgs internal_call strict warn_loaded timing verbose _from_loading configs fancyprint manifest ignore_loaded detachable
    # monomorphize this to avoid latency problems
    _precompilepkgs(pkgs, internal_call, strict, warn_loaded, timing, verbose, _from_loading,
                   configs isa Vector{Config} ? configs : [configs],
                   io isa IOContext ? io : IOContext(io), fancyprint, manifest, ignore_loaded, detachable)
end

## Background lifecycle

function is_precompiling_in_background()
    @lock BG (BG.task !== nothing && !istaskdone(BG.task))
end

# Check if `pkg` is currently being precompiled by a background task.
# This should be called while holding require_lock to avoid races.
# Returns true if the package is pending.
function is_package_pending(pkg::PkgId)
    @lock BG begin
        BG.task === nothing && return false
        istaskdone(BG.task) && return false
        return get(BG.pending_pkgids, pkg, 0) > 0
    end
end

# Monitor the precompile progress for `pkg` until that package finishes.
# This should be called after unlocking require_lock.
function wait_for_pending_package(pkg::PkgId)
    is_package_pending(pkg) || return false
    printpkgstyle(stderr, :Info, "$(pkg.name) is currently being precompiled in the background. Waiting for it to finish...", color = Base.info_color())
    monitor_background_precompile(stderr, false, pkg)
    return true
end

# Broadcast a signal to all active precompilation subprocesses.
function broadcast_signal(sig::Int32)
    @lock BG begin
        for ch in BG.signal_channels
            try; isopen(ch) && put!(ch, sig); catch e; e isa InvalidStateException || rethrow(); end
        end
    end
end
broadcast_signal(sig::Integer) = broadcast_signal(Int32(sig))

# Record that `pkg` will not be precompiled by this session (e.g. missing source,
# `__precompile__(false)`), so watchers waiting for it are released promptly.
function mark_completed_pkgid!(pkg::PkgId)
    @lock BG @lock BG.pkg_done begin
        push!(BG.completed_pkgids, pkg)
        notify(BG.pkg_done)
    end
    return nothing
end

function stop_background_precompile(; graceful::Bool = true)
    @lock BG begin
        if BG.task !== nothing && !istaskdone(BG.task)
            if graceful
                BG.interrupt_requested = true
            else
                BG.cancel_requested = true
                broadcast_signal(Base.SIGKILL) # re-entrant: broadcast_signal also @lock's BG
            end
            try; close(BG.work_channel); catch; end
            return true
        end
        return false
    end
end

const register_atexit_hook = Base.OncePerProcess{Nothing}() do
    Base.atexit() do
        task = @lock BG BG.task
        task === nothing && return
        istaskdone(task) && return
        stop_background_precompile(; graceful=false)
        wait(task)
    end
    nothing
end

const _confirm_messages = Dict{Symbol, String}(
    :cancel => "cancel precompilation",
    :info   => "send profiling signal",
)

function keyboard_tip(s::BackgroundPrecompileState)
    s.monitoring || return "", :default
    s.key_listening || return "", :default
    if s.confirming !== :none
        remaining = max(0, ceil(Int, s.confirm_deadline - time()))
        msg = get(_confirm_messages, s.confirming, string(s.confirming))
        return "Press Enter to $(msg) (ignoring in $(remaining)s)", Base.info_color()
    end
    if s.detachable
        return "Press `?` for help, `c` to cancel, `d` to detach.", :default
    else
        return "Press `?` for help, `c` to cancel.", :default
    end
end

function monitor_background_precompile(io::IO = stderr, detachable::Bool = true, wait_for_pkg::Union{Nothing, PkgId} = nothing;
                                       key_controls::Union{Bool, Nothing} = nothing)
    # By default only enable key controls when this task is the foreground task (see #61563, #61698).
    # Falls back to roottask when no foreground task is registered (e.g. non-REPL interactive scripts).
    key_controls = @something key_controls current_task() === something(Base.foreground_task(), Base.roottask)
    local completed_at::Union{Nothing, Float64}
    local task

    @lock BG begin
        completed_at = BG.completed_at
        task = BG.task
    end

    if task === nothing || istaskdone(task)
        if completed_at !== nothing
            elapsed = time() - completed_at
            time_str = if elapsed < 60
                "$(round(Int, elapsed)) seconds ago"
            elseif elapsed < 3600
                "$(round(Int, elapsed / 60)) minutes ago"
            elseif elapsed < 86400
                "$(round(Int, elapsed / 3600)) hours ago"
            else
                "$(round(Int, elapsed / 86400)) days ago"
            end
            printpkgstyle(io, :Info, "Background precompilation completed $time_str", color = Base.info_color())
            result = @lock BG BG.result
            if result !== nothing && !isempty(result)
                println(io, "  ", result)
            end
        else
            printpkgstyle(io, :Info, "No background precompilation is running or has been run in this session", color = Base.info_color())
        end
        return
    end

    # Enable output from do_precompile
    @lock BG BG.monitoring = true

    exit_requested = Ref(false)
    cancel_requested = Ref(false)
    interrupt_requested = Ref(false)

    # Start a task to listen for keypresses. Skipped if another reader already holds
    # raw mode on stdin (e.g. runtests.jl's stdin_monitor).
    key_task = if key_controls && stdin isa Base.TTY
        Threads.@spawn :samepool try
            trylock(stdin.raw_lock) || return
            @lock BG begin
                BG.detachable = detachable
                BG.confirming = :none
                BG.key_listening = true
            end
            buffered_input = UInt8[]
            try
                term = Base.Terminals.TTYTerminal(get(ENV, "TERM", "dumb"), stdin, stdout, stderr)
                Base.Terminals.raw!(term, true)
                # Drain any pre-existing input (e.g. pasted or pre-typed text)
                # before entering the key listener. This avoids accidentally
                # triggering menu actions from stale input (see #61520).
                # The drained bytes are replayed into stdin on exit.
                # We must start_reading + yield first so libuv delivers any
                # bytes sitting in the kernel tty buffer into stdin.buffer.
                Base.start_reading(stdin)
                yield()
                while bytesavailable(stdin) > 0
                    push!(buffered_input, read(stdin, UInt8))
                end
                try
                    while true
                        completed = @lock BG (BG.completed_at !== nothing)
                        if completed || exit_requested[] || cancel_requested[] || interrupt_requested[]
                            break
                        end
                        Base.wait_readnb(stdin, 1)
                        completed = @lock BG (BG.completed_at !== nothing)
                        if completed || exit_requested[] || cancel_requested[] || interrupt_requested[]
                            break
                        end
                        bytesavailable(stdin) > 0 || continue
                        c = read(stdin, Char)
                        # If waiting for confirmation, Enter confirms, anything else aborts
                        confirmed_action = @lock BG begin
                            prev = BG.confirming
                            BG.confirming = :none
                            (prev !== :none && c in ('\r', '\n')) ? prev : :none
                        end
                        if confirmed_action == :cancel
                            cancel_requested[] = true
                            println(io)
                            @lock BG BG.cancel_requested = true
                            broadcast_signal(Base.SIGKILL)
                            break
                        elseif confirmed_action == :info
                            @lock BG BG.info_requested = true
                            broadcast_signal(Sys.isapple() ? Base.SIGINFO : Base.SIGUSR1)
                            continue
                        end
                        if c in ('c', 'C')
                            @lock BG begin
                                BG.confirming = :cancel
                                BG.confirm_deadline = time() + 5.0
                            end
                        elseif detachable && c in ('d', 'D', 'q', 'Q', ']')
                            exit_requested[] = true
                            println(io)  # newline after keypress
                            break
                        elseif c == '\x03'  # Ctrl-C
                            interrupt_requested[] = true
                            println(io)  # newline after keypress
                            @lock BG BG.interrupt_requested = true
                            broadcast_signal(Base.SIGINT)
                            break
                        elseif c in ('i', 'I')
                            @lock BG begin
                                BG.confirming = :info
                                BG.confirm_deadline = time() + 5.0
                            end
                        elseif c in ('v', 'V')
                            @lock BG BG.verbose = !BG.verbose
                        elseif c in ('?', 'h', 'H')
                            @lock io begin
                                println(io, "  Keyboard shortcuts:", ansi_cleartoendofline)
                                println(io, "    c       Cancel precompilation via killing subprocesses (press Enter to confirm)", ansi_cleartoendofline)
                                if detachable
                                    println(io, "    d/q/]   Detach (precompilation continues in background)", ansi_cleartoendofline)
                                end
                                println(io, "    i       Send profiling signal to subprocesses (press Enter to confirm)", ansi_cleartoendofline)
                                fields = Sys.iswindows() ? "elapsed time and PID" : "elapsed time, PID, CPU% and memory"
                                println(io, "    v       Toggle verbose mode (show $(fields) for each worker)", ansi_cleartoendofline)
                                println(io, "    Ctrl-C  Interrupt (sends SIGINT, shows output)", ansi_cleartoendofline)
                                println(io, "    ?/h     Show this help", ansi_cleartoendofline)
                            end
                        end
                    end
                finally
                    Base.Terminals.raw!(term, false)
                end
            catch err
                err isa EOFError && return
                exit_requested[] = true
                rethrow()
            finally
                # Replay any buffered input back into stdin so the REPL
                # (or whatever reads stdin next) sees it as typed text.
                if !isempty(buffered_input)
                    lock(stdin.cond)
                    try
                        write(stdin.buffer, buffered_input)
                        notify(stdin.cond)
                    finally
                        unlock(stdin.cond)
                    end
                end
                Base.reseteof(stdin)
                @lock BG begin
                    BG.confirming = :none
                    BG.key_listening = false
                end
                unlock(stdin.raw_lock)
            end
        finally
            @lock BG.task_done notify(BG.task_done)
        end
    else
        nothing
    end

    # Wake up key_task by signaling EOF on stdin so wait_readnb returns
    wake_key_task = () -> begin
        if key_task !== nothing && !istaskdone(key_task)
            lock(stdin.cond)
            try
                stdin.status = Base.StatusEOF
                notify(stdin.cond)
            finally
                unlock(stdin.cond)
            end
        end
    end

    # If waiting for a specific package, spawn a watcher that exits when it's done
    if wait_for_pkg !== nothing
        Threads.@spawn :samepool begin
            @lock BG.pkg_done begin
                # Wait for the package to appear in pending_pkgids (it may not be
                # registered yet if the request was just injected via work_channel
                # and drain_work_channel! hasn't processed it).
                # Also check completed_pkgids in case the package was added and
                # removed before we started watching.
                while get(BG.pending_pkgids, wait_for_pkg, 0) == 0 && wait_for_pkg ∉ BG.completed_pkgids && BG.completed_at === nothing
                    wait(BG.pkg_done)
                end
                # Now wait for it to finish
                while get(BG.pending_pkgids, wait_for_pkg, 0) > 0
                    wait(BG.pkg_done)
                end
            end
            exit_requested[] = true
            @lock BG.task_done notify(BG.task_done)
        end
    else
        nothing
    end

    return try
        # Wait for task completion or user action
        @lock BG.task_done begin
            while !exit_requested[] && !cancel_requested[] && !interrupt_requested[]
                BG.completed_at !== nothing && break
                wait(BG.task_done)
            end
        end

        # If user requested cancel, stop the background task
        if cancel_requested[]
            key_task !== nothing && wait(key_task)
            print(io, ansi_enablecursor, ansi_cleartoend)
            printpkgstyle(io, :Info, "Canceling precompilation...$(ansi_cleartoend)", color = Base.info_color())
            # Wait for the task to emit its final report before clearing
            # `BG.monitoring`, which gates that report's output.
            wait(task; throw=false)
            @lock BG BG.monitoring = false
            return
        end

        # If user requested interrupt, wait for background task to finish cleanly
        if interrupt_requested[]
            key_task !== nothing && wait(key_task)
            # Escalate to SIGKILL if background task doesn't finish promptly
            escalation = Timer(5) do _
                broadcast_signal(Base.SIGKILL)
            end
            wait(task; throw=false)
            close(escalation)
            return
        end

        # If we were waiting for a specific package and it finished, clean up silently
        if exit_requested[] && wait_for_pkg !== nothing
            @lock BG BG.monitoring = false
            if key_task !== nothing
                wake_key_task()
                wait(key_task)
            end
            print(io, ansi_enablecursor, ansi_cleartoend)
            return
        end

        # If user requested exit, clean up and return
        if exit_requested[]
            @lock BG BG.monitoring = false
            key_task !== nothing && wait(key_task)
            print(io, ansi_enablecursor, ansi_cleartoend)
            n_pending = @lock BG length(BG.pending_pkgids)
            progress = n_pending > 0 ? " ($n_pending packages remaining)." : "."
            printpkgstyle(io, :Precompiling, "detached$(progress) Precompilation will continue in the background. Monitor with `precompile --monitor`.$(ansi_cleartoend)", color = Base.info_color())
            return
        end

        # Normal completion - signal key_task to exit and wait
        if key_task !== nothing
            wake_key_task()
            wait(key_task)
        end

        wait(task; throw=false)
    catch
        # Clean up on error
        @lock BG BG.monitoring = false
        if key_task !== nothing
            exit_requested[] = true
            wake_key_task()
            try; wait(key_task); catch; end
        end
        rethrow()
    end
end

function launch_background_precompile(pkgs::Union{Vector{String}, Vector{PkgId}},
                                       internal_call::Bool,
                                       strict::Bool,
                                       warn_loaded::Bool,
                                       timing::Bool,
                                       verbose::Bool,
                                       _from_loading::Bool,
                                       configs::Vector{Config},
                                       io::IOContext,
                                       fancyprint::Bool,
                                       manifest::Bool,
                                       ignore_loaded::Bool,
                                       detachable::Bool)
    # Stop any existing background precompilation
    @lock BG begin
        if BG.task !== nothing && !istaskdone(BG.task)
            BG.interrupt_requested = true
            @lock BG.task_done notify(BG.task_done)
        end
    end

    # Wait for previous task to complete
    old_task = @lock BG BG.task
    if old_task !== nothing
        wait(old_task)
    end

    @lock BG begin
        BG.interrupt_requested = false
        BG.cancel_requested = false
        BG.info_requested = false
        BG.verbose = verbose
        empty!(BG.signal_channels)
        BG.monitoring = true
        BG.completed_at = nothing
        BG.result = nothing
        BG.return_value = nothing
        BG.exception = nothing
        empty!(BG.pending_pkgids)
        empty!(BG.completed_pkgids)
        BG.work_channel = Channel{PrecompileRequest}(Inf)
    end

    # Capture necessary context for background task
    pkg_names = pkgs isa Vector{String} ? copy(pkgs) : String[pkg.name for pkg in pkgs]

    # Register an atexit hook (once) to cleanly shut down background precompilation
    # before the event loop is torn down.
    register_atexit_hook()

    # Launch new background precompilation
    @lock BG begin
        wc = BG.work_channel
        BG.task = Threads.@spawn :samepool begin
            try
                ret = do_precompile(pkg_names, internal_call, strict, warn_loaded, timing, _from_loading,
                                    configs, io, fancyprint, manifest, ignore_loaded, detachable, wc)

                @lock BG begin
                    BG.return_value = ret
                end
            catch e
                @lock BG begin
                    if BG.interrupt_requested || BG.cancel_requested
                        BG.result = "Background precompilation was interrupted"
                    else
                        BG.exception = e
                        BG.result = "Background precompilation failed: $(sprint(showerror, e))"
                    end
                end
            finally
                close(wc)
                # Drain pending requests with error
                while isready(wc)
                    req = try; take!(wc); catch; break; end
                    try; put!(req.result, InterruptException()); catch; end
                end
                @lock BG begin
                    BG.task = nothing
                    BG.interrupt_requested = false
                    BG.cancel_requested = false
                    BG.info_requested = false
                    foreach(close, BG.signal_channels)
                    empty!(BG.signal_channels)
                    @lock BG.pkg_done begin
                        empty!(BG.pending_pkgids)
                        notify(BG.pkg_done)
                    end
                    BG.monitoring = false
                    BG.completed_at = time()
                    @lock BG.task_done notify(BG.task_done)
                end
            end
        end
    end

    return nothing
end

function _collect_reachable!(pkg_uuids::Set{UUID}, deps::Dict{UUID, Vector{UUID}}, uuid::UUID)
    uuid in pkg_uuids && return
    push!(pkg_uuids, uuid)
    for dep_uuid in get(Vector{UUID}, deps, uuid)
        _collect_reachable!(pkg_uuids, deps, dep_uuid)
    end
end

function _precompilepkgs(pkgs::Union{Vector{String}, Vector{PkgId}},
                         internal_call::Bool,
                         strict::Bool,
                         warn_loaded::Bool,
                         timing::Bool,
                         verbose::Bool,
                         _from_loading::Bool,
                         configs::Vector{Config},
                         io::IOContext,
                         fancyprint′::Bool,
                         manifest::Bool,
                         ignore_loaded::Bool,
                         detachable::Bool)
    # Try to inject into a running background task, else launch a new one, under
    # launch_lock so concurrent callers cannot spawn competing background tasks.
    local req = nothing
    injected = @lock launch_lock begin
        did_inject = @lock BG begin
            if BG.task !== nothing && !istaskdone(BG.task) &&
                    isopen(BG.work_channel)
                pkg_names = pkgs isa Vector{String} ? copy(pkgs) : String[pkg.name for pkg in pkgs]
                req = PrecompileRequest(pkg_names, internal_call, strict, warn_loaded, timing, _from_loading,
                                        configs, io, fancyprint′, manifest, ignore_loaded, detachable,
                                        Channel{Any}(1))
                try
                    # Enable verbose before enqueueing, and only ever turn it on so a
                    # non-verbose merge doesn't disable an already-verbose run.
                    verbose && (BG.verbose = true)
                    put!(BG.work_channel, req)
                    true
                catch
                    req = nothing
                    false
                end
            else
                false
            end
        end
        if !did_inject
            launch_background_precompile(pkgs, internal_call, strict, warn_loaded, timing, verbose, _from_loading,
                                         configs, io, fancyprint′, manifest, ignore_loaded, detachable)
        end
        did_inject
    end
    if injected
        printpkgstyle(io, :Precompiling, "Merging precompilation request into existing run...", color = Base.info_color())
    end

    if req !== nothing
        # Injected into existing task — monitor until our package finishes, then read result
        wait_for_pkg = if _from_loading && length(pkgs) == 1
            pkgs[1] isa PkgId ? pkgs[1] : Base.identify_package(pkgs[1])
        else
            nothing
        end
        monitor_background_precompile(io.io, detachable, wait_for_pkg)
        if _from_loading
            # _from_loading: package just left pending_pkgids, waiter will put result shortly
            result = take!(req.result)
            result isa Exception && throw(result)
            return result
        end
        # Interactive: if result is ready (task completed), use it; if detached, return nothing
        if isready(req.result)
            result = take!(req.result)
            result isa Exception && throw(result)
            return result
        end
        return nothing
    end

    # Launched new task — wait for full completion
    monitor_background_precompile(io.io, detachable)

    local ret_val, ret_ex
    @lock BG begin
        ret_val = BG.return_value
        ret_ex = BG.exception
    end
    ret_ex !== nothing && throw(ret_ex)
    return ret_val
end

## Session implementation

# Mach timebase info for converting Mach absolute time → nanoseconds on macOS.
# Queried once per process at runtime (not at sysimage build time).
@static if Sys.isapple()
    const _mach_timebase = Base.OncePerProcess{Tuple{UInt64,UInt64}}() do
        buf = zeros(UInt32, 2)
        ccall(:mach_timebase_info, Cvoid, (Ptr{UInt32},), buf)
        (UInt64(buf[1]), UInt64(buf[2]))
    end
end

# Read cumulative CPU time (user+system) in nanoseconds and RSS in bytes for a process.
# Returns (cpu_ns=0, rss_bytes=0) if the process no longer exists or on unsupported platforms.
function process_stats(pid::Int32)
    @static if Sys.islinux()
        try
            stat = read("/proc/$(pid)/stat", String)
            # Field 2 (comm) is in parens and may contain spaces; skip past it
            i = findlast(')', stat)
            i === nothing && return (cpu_ns=UInt64(0), rss_bytes=UInt64(0))
            fields = split(@view(stat[nextind(stat, i):end]))
            # fields[1] = state (field 3), so utime=field 14 is at index 12,
            # stime=field 15 at index 13, rss=field 24 at index 22
            length(fields) >= 22 || return (cpu_ns=UInt64(0), rss_bytes=UInt64(0))
            utime = parse(UInt64, fields[12])
            stime = parse(UInt64, fields[13])
            rss_pages = parse(UInt64, fields[22])
            # CLK_TCK is almost always 100 on Linux; 1 tick = 10ms = 10_000_000 ns
            cpu_ns = (utime + stime) * UInt64(10_000_000)
            rss_bytes = rss_pages * UInt64(ccall(:getpagesize, Cint, ()))
            return (; cpu_ns, rss_bytes)
        catch
            return (cpu_ns=UInt64(0), rss_bytes=UInt64(0))
        end
    elseif Sys.isapple()
        try
            buf = Vector{UInt8}(undef, 96)  # sizeof(struct proc_taskinfo)
            ret = ccall((:proc_pidinfo, "libproc"), Cint,
                        (Cint, Cint, UInt64, Ptr{UInt8}, Cint),
                        pid, Cint(4), UInt64(0), buf, Cint(96))
            ret <= 0 && return (cpu_ns=UInt64(0), rss_bytes=UInt64(0))
            # pti_resident_size at offset 8 (bytes)
            rss_bytes = GC.@preserve buf unsafe_load(Ptr{UInt64}(pointer(buf) + 8))
            # pti_total_user at offset 16, pti_total_system at offset 24 (Mach absolute time)
            user = GC.@preserve buf unsafe_load(Ptr{UInt64}(pointer(buf) + 16))
            sys  = GC.@preserve buf unsafe_load(Ptr{UInt64}(pointer(buf) + 24))
            # Convert Mach absolute time to nanoseconds via mach_timebase_info
            numer, denom = _mach_timebase()
            cpu_ns = div((user + sys) * numer, denom)
            return (; cpu_ns, rss_bytes)
        catch
            return (cpu_ns=UInt64(0), rss_bytes=UInt64(0))
        end
    else
        return (cpu_ns=UInt64(0), rss_bytes=UInt64(0))
    end
end

# Compute CPU% and RSS for all active PIDs since last poll.
# Mutates cpu_pcts and rss in place.
function poll_process_stats!(cpu_pcts::Dict{Int32, Float64}, rss::Dict{Int32, UInt64},
                             prev_cpu_times::Dict{Int32, UInt64}, jobs::Dict{PkgConfig, PrecompileJob}, dt::Float64)
    empty!(cpu_pcts)
    empty!(rss)
    @static if !(Sys.islinux() || Sys.isapple())
        return
    end
    for (_, job) in jobs
        has_pid(job) || continue
        pid = job.pid
        haskey(cpu_pcts, pid) && continue
        stats = process_stats(pid)
        stats.cpu_ns == 0 && continue
        prev = get(prev_cpu_times, pid, stats.cpu_ns)
        delta = stats.cpu_ns >= prev ? stats.cpu_ns - prev : UInt64(0)
        prev_cpu_times[pid] = stats.cpu_ns
        pct = dt > 0 ? (delta / 1.0e9) / dt * 100.0 : 0.0
        cpu_pcts[pid] = min(pct, 999.9)
        if stats.rss_bytes > 0
            rss[pid] = stats.rss_bytes
            stats.rss_bytes > job.peak_rss_bytes && (job.peak_rss_bytes = stats.rss_bytes)
        end
    end
    pids_set = Set(job.pid for (_, job) in jobs if has_pid(job))
    for pid in keys(prev_cpu_times)
        pid in pids_set || delete!(prev_cpu_times, pid)
    end
    return
end

# Lightweight peak-RSS-only sampler used in non-fancy verbose timing mode,
# where the live progress UI (which would otherwise drive `poll_process_stats!`)
# is not running but the per-package verbose timing column still wants peak RSS.
function sample_peak_rss!(jobs::Dict{PkgConfig, PrecompileJob})
    @static if !(Sys.islinux() || Sys.isapple())
        return
    end
    seen = Set{Int32}()
    for (_, job) in jobs
        has_pid(job) || continue
        pid = job.pid
        pid in seen && continue
        push!(seen, pid)
        stats = process_stats(pid)
        if stats.rss_bytes > job.peak_rss_bytes
            job.peak_rss_bytes = stats.rss_bytes
        end
    end
    return
end

function should_stop(s::PrecompileSession)
    ir, cr = @lock BG begin
        BG.interrupt_requested, BG.cancel_requested
    end
    if ir || cr
        @lock s.print_lock begin
            s.interrupted = s.interrupted || ir
            s.canceled = s.canceled || cr
            if !s.interrupted_or_done
                s.interrupted_or_done = true
                foreach(notify, values(s.was_processed))
                notify(s.first_started)
            end
        end
        return true
    end
    return s.interrupted_or_done
end

function make_signal_channel()
    ch = Channel{Int32}(32)
    @lock BG push!(BG.signal_channels, ch)
    return ch
end

function describe_pkg(s::PrecompileSession, pkg::PkgId, is_project_dep::Bool, is_serial_dep::Bool, flags::Cmd, cacheflags::Base.CacheFlags)
    name = full_name(s.ext_to_parent, pkg)
    name = is_project_dep ? name : color_string(name, :light_black, s.hascolor)
    if is_serial_dep
        name *= color_string(" (serial)", :light_black, s.hascolor)
    end
    if length(s.configs) > 1 && !isempty(flags)
        config_str = join(flags, " ")
        name *= color_string(" `$config_str`", :light_black, s.hascolor)
    end
    if length(s.configs) > 1
        config_str = join(Base.translate_cache_flags(cacheflags, Base.DefaultCacheFlags), " ")
        name *= color_string(" $config_str", :light_black, s.hascolor)
    end
    return name
end

function spawn_print_loop!(s::PrecompileSession)
    Threads.@spawn :samepool begin
        cursor_disabled = false
        t = nothing
        try
            wait(s.first_started)
            (isempty(s.pkg_queue) || s.interrupted_or_done) && return
            @lock s.print_lock begin
                if BG.monitoring
                    printpkgstyle(s.logio, :Precompiling, s.target)
                    BG.verbose && println(s.logio, format_verbose_timing_header())
                end
            end
            t = Timer(0; interval=1/10)
            anim_chars = ["◐","◓","◑","◒"]
            i = 1
            bar = MiniProgressBar(; indent=0, header = "Precompiling packages ", color = :green, percentage=false, always_reprint=true)
            bar.max = s.n_total - s.n_already_precomp
            final_loop = false
            last_poll_time = time()
            cpu_pcts = Dict{Int32, Float64}()
            rss_bytes = Dict{Int32, UInt64}()
            while !s.printloop_should_exit
                # Propagate cancel/interrupt requested via BG into the session so the loop exits.
                should_stop(s)
                @lock s.print_lock begin
                    verbose = BG.verbose
                    now_time = time()
                    dt = now_time - last_poll_time
                    if verbose && dt >= 0.5
                        poll_process_stats!(cpu_pcts, rss_bytes, s.prev_cpu_times, s.jobs, dt)
                        last_poll_time = now_time
                    end
                    term_size = displaysize(s.logio)::Tuple{Int, Int}
                    num_deps_show = max(term_size[1] - 3, 2) # show at least 2 deps
                    pkg_queue_show = if !s.interrupted_or_done && length(s.pkg_queue) > num_deps_show
                        last(s.pkg_queue, num_deps_show)
                    else
                        s.pkg_queue
                    end
                    i_local = i
                    final_loop_local = final_loop
                    tip, tip_color = @lock BG begin
                        if BG.confirming !== :none && time() >= BG.confirm_deadline
                            BG.confirming = :none
                        end
                        keyboard_tip(BG)
                    end
                    str_ = sprint(; context=s.logio) do iostr
                        if i_local > 1
                            print(iostr, ansi_cleartoend)
                        end
                        bar.header = verbose ? "Precompiling ($(s.num_tasks) tasks) " : "Precompiling packages "
                        # max(0,...) guards against a race where the print loop runs after
                        # n_already_precomp is incremented but before n_done is incremented,
                        # which would otherwise produce a negative value and crash repeat().
                        bar.current = max(0, s.n_done - s.n_already_precomp)
                        bar.max = max(0, s.n_total - s.n_already_precomp)
                        termwidth = (displaysize(s.logio)::Tuple{Int,Int})[2]
                        if !final_loop_local
                            tip_width = isempty(tip) ? 0 : textwidth(tip) + 1
                            bar_termwidth = termwidth - tip_width
                            s_bar = sprint(io -> show_progress(io, bar; termwidth=bar_termwidth, carriagereturn=false); context=s.logio)
                            if !isempty(tip)
                                s_bar = string(s_bar, " ", color_string(tip, tip_color, s.hascolor))
                            end
                            print(iostr, Base._truncate_at_width_or_chars(true, s_bar, termwidth), "\n")
                        end
                        for pkg_config in pkg_queue_show
                            dep, config = pkg_config
                            loaded = s.warn_loaded && (dep in s.start_loaded_modules)
                            flags, cacheflags = config
                            name = describe_pkg(s, dep, dep in s.project_deps, dep in s.serial_deps, flags, cacheflags)
                            job = s.jobs[pkg_config]
                            line = if is_soft_error(job)
                                string(color_string("  ? ", Base.warn_color(), s.hascolor), name)
                            elseif is_failed(job)
                                string(color_string("  ✗ ", Base.error_color(), s.hascolor), name)
                            elseif is_recompiled(job)
                                !loaded && s.interrupted_or_done && continue
                                loaded || Base.errormonitor(Threads.@spawn :samepool begin
                                    sleep(1);
                                    @lock s.print_lock filter!(!isequal(pkg_config), s.pkg_queue)
                                end)
                                string(color_string("  ✓ ", loaded ? Base.warn_color() : :green, s.hascolor), name)
                            elseif is_started(job) && s.interrupted_or_done
                                # Cancel/interrupt: show a static marker for jobs that actually had
                                # a subprocess running, so the user sees what was in flight without
                                # it looking still-running. Skip jobs that hadn't reached subprocess
                                # spawn yet (e.g. still acquiring the parallel_limiter).
                                had_pid(job) || continue
                                string(color_string("  - ", :light_black, s.hascolor), name)
                            elseif is_started(job)
                                anim_char = anim_chars[(i_local + Int(dep.name[1])) % length(anim_chars) + 1]
                                anim_char_colored = dep in s.project_deps ? anim_char : color_string(anim_char, :light_black, s.hascolor)
                                waiting = if is_locked(job)
                                    color_string(" Being precompiled by $(job.lock_holder)", Base.info_color(), s.hascolor)
                                elseif is_waiting(job)
                                    color_string(" Waiting for background task / IO / timer. Interrupt to inspect", Base.warn_color(), s.hascolor)
                                else
                                    ""
                                end

                                pid_info = if verbose && has_pid(job)
                                    elapsed_str = string(round(Int, now_time - job.started_at), "s")
                                    pid = job.pid
                                    pct = get(cpu_pcts, pid, -1.0)
                                    pct_str = pct >= 0 ? string(" | cpu ", round(Int, pct), "%") : ""
                                    mem = get(rss_bytes, pid, UInt64(0))
                                    mem_str = mem > 0 ? string(" | ", Base.format_bytes(mem)) : ""
                                    color_string(" $(elapsed_str) | pid $(pid)$(pct_str)$(mem_str)", Base.info_color(), s.hascolor)
                                else
                                    ""
                                end
                                string("  ", anim_char_colored, " ", name, pid_info, waiting)
                            else
                                string("    ", name)
                            end
                            println(iostr, Base._truncate_at_width_or_chars(true, line, termwidth))
                        end
                    end
                    n_print_rows = count("\n", str_)
                    s.printloop_should_exit = s.interrupted_or_done && final_loop
                    final_loop = s.interrupted_or_done
                    i += 1
                    if BG.monitoring
                        if s.fancyprint && !cursor_disabled
                            print(s.logio, ansi_disablecursor)
                            cursor_disabled = true
                        end
                        if s.printloop_should_exit
                            print(s.logio, str_)
                        else
                            print(s.logio, str_, ansi_moveup(n_print_rows), ansi_movecol1)
                        end
                    elseif cursor_disabled
                        print(s.logio, ansi_enablecursor)
                        cursor_disabled = false
                    end
                end
                wait(t)
            end
        finally
            t === nothing || close(t)
            cursor_disabled && print(s.logio, ansi_enablecursor)
        end
    end
end

function precompilepkgs_monitor_std(s::PrecompileSession, pkg_config, job::PrecompileJob, pipe, single_requested_pkg::Bool)
    pkg, _ = pkg_config
    liveprinting = false
    thistaskwaiting = false
    while !eof(pipe)
        str = readline(pipe, keep=true)
        if startswith(str, Base.PRECOMPILE_VERBOSE_TIMING_MARKER)
            job.verbose_timing = strip(str)
            continue
        end
        if single_requested_pkg && (liveprinting || !isempty(str))
            BG.monitoring && @lock s.print_lock begin
                if !liveprinting
                    liveprinting = true
                    s.pkg_liveprinted = pkg
                end
                # in fancy mode clear the progress bar residue from the line first
                print(s.io, s.fancyprint ? ansi_cleartoendofline : "", str)
            end
        end
        write(job.output, str)
        if !thistaskwaiting && occursin("Waiting for background task / IO / timer", str)
            thistaskwaiting = true
            !liveprinting && !s.fancyprint && BG.monitoring && @lock s.print_lock begin
                println(s.io, full_name(s.ext_to_parent, pkg), color_string(str, Base.warn_color(), s.hascolor))
            end
            job.waiting_for_bg = true
        elseif !thistaskwaiting
            # XXX: don't just re-enable IO for random packages without printing the context for them first
            !liveprinting && !s.fancyprint && BG.monitoring && @lock s.print_lock begin
                print(s.io, str)
            end
        end
    end
end

# Can be merged with `maybe_cachefile_lock` in loading?
# Wraps the precompilation function `f` with cachefile lock handling.
# Returns the result from `f()`, which can be:
#   - `nothing`: cache already existed
#   - `Tuple{String, Union{Nothing, String}}`: this process just compiled
#   - `Exception`: compilation failed
function precompile_pkgs_maybe_cachefile_lock(f, s::PrecompileSession, pkg_config, job::PrecompileJob, fullname)
    if !(isdefined(Base, :mkpidlock_hook) && isdefined(Base, :trymkpidlock_hook) && Base.isdefined(Base, :parse_pidfile_hook))
        return f()
    end
    pkg, config = pkg_config
    _, cacheflags = config
    stale_age = Base.compilecache_pidlock_stale_age
    pidfile = Base.compilecache_pidfile_path(pkg, flags=cacheflags)
    cachefile = @invokelatest Base.trymkpidlock_hook(f, pidfile; stale_age)
    if cachefile === false
        pid, hostname, _ = @invokelatest Base.parse_pidfile_hook(pidfile)
        job.lock_holder = if isempty(hostname) || hostname == gethostname()
            if pid == getpid()
                "an async task in this process (pidfile: $pidfile)"
            else
                "another process (pid: $pid, pidfile: $pidfile)"
            end
        else
            "another machine (hostname: $hostname, pid: $pid, pidfile: $pidfile)"
        end
        !s.fancyprint && BG.monitoring && @lock s.print_lock begin
            println(s.io, "    ", fullname, color_string(" Being precompiled by $(job.lock_holder)", Base.info_color(), s.hascolor))
        end
        Base.release(s.parallel_limiter) # release so other work can be done while waiting
        try
            # wait until the lock is available
            cachefile = @invokelatest Base.mkpidlock_hook(() -> begin
                    job.lock_holder = ""
                    Base.acquire(f, s.parallel_limiter; cancel=() -> should_stop(s))
                end,
                pidfile; stale_age)
        finally
            Base.acquire(s.parallel_limiter; cancel=() -> should_stop(s)) # re-acquire so the outer release is balanced
        end
    end
    return cachefile
end

function spawn_precompile_tasks!(s::PrecompileSession;
        direct_deps, was_processed, configs, circular_deps,
        requested_pkgids, pkg_names, requested_pkgs, from_loading)
    batch_tasks = Task[]
    for (pkg, deps) in direct_deps
        cachepaths = Base.find_all_in_cache_path(pkg)
        freshpaths = String[]
        @lock s.cache_lock s.cachepath_cache[pkg] = freshpaths
        sourcespec = Base.locate_package_load_spec(pkg)
        single_requested_pkg = length(requested_pkgs) == 1 &&
            (pkg in requested_pkgids || pkg.name in pkg_names)
        for config in configs
            pkg_config = (pkg, config)
            # look up under print_lock (the drainer may insert into s.jobs concurrently)
            job = @lock s.print_lock s.jobs[pkg_config]
            if sourcespec === nothing
                mark_failed!(job, "Error: Missing source file for $(pkg)")
                mark_completed_pkgid!(pkg)
                @lock s.print_lock (s.n_done += 1)
                notify(was_processed[pkg_config])
                continue
            end
            if from_loading && single_requested_pkg && occursin(r"\b__precompile__\(\s*false\s*\)", read(sourcespec.path, String))
                @lock s.print_lock begin
                    Base.@logmsg s.logcalls "Disabled precompiling $(repr("text/plain", pkg)) since the text `__precompile__(false)` was found in file."
                end
                mark_completed_pkgid!(pkg)
                @lock s.print_lock (s.n_done += 1)
                notify(was_processed[pkg_config])
                continue
            end
            @lock BG @lock BG.pkg_done begin
                BG.pending_pkgids[pkg] = get(BG.pending_pkgids, pkg, 0) + 1
                notify(BG.pkg_done)
            end
            flags, cacheflags = config
            task = Threads.@spawn :samepool try
                loaded = s.warn_loaded && (pkg in s.start_loaded_modules)
                for dep in deps
                    wait(was_processed[(dep,config)])
                    if should_stop(s)
                        return
                    end
                end
                circular = pkg in circular_deps
                freshpath = @lock s.cache_lock Base.compilecache_freshest_path(pkg; ignore_loaded=s.ignore_loaded,
                    stale_cache=s.stale_cache, cachepath_cache=s.cachepath_cache, cachepaths, sourcespec, flags=cacheflags)
                is_stale = freshpath === nothing
                if is_stale && !circular && Base.CACHE_FETCH_HOOK[] !== nothing
                    # a cache-fetch hook gets one chance to materialize a
                    # cachefile before we schedule a local compile; this runs
                    # after the dep waits above, so dependency cachefiles are
                    # in place for the hook to key against
                    if Base.maybe_fetch_cache(pkg, sourcespec.path)
                        local fetched_cachepaths = Base.find_all_in_cache_path(pkg)
                        freshpath = @lock s.cache_lock Base.compilecache_freshest_path(pkg; ignore_loaded=s.ignore_loaded,
                            stale_cache=s.stale_cache, cachepath_cache=s.cachepath_cache,
                            cachepaths=fetched_cachepaths, sourcespec, flags=cacheflags)
                        is_stale = freshpath === nothing
                    end
                end
                if !is_stale
                    push!(freshpaths, freshpath)
                end
                if !circular && is_stale
                    Base.acquire(s.parallel_limiter; cancel=() -> should_stop(s))
                    is_serial_dep = pkg in s.serial_deps
                    is_project_dep = pkg in s.project_deps

                    std_pipe = Base.link_pipe!(Pipe(); reader_supports_async=true, writer_supports_async=true)
                    t_monitor = Threads.@spawn :samepool precompilepkgs_monitor_std(s, pkg_config, job, std_pipe,
                        single_requested_pkg)

                    name = describe_pkg(s, pkg, is_project_dep, is_serial_dep, flags, cacheflags)
                    try
                        @lock s.print_lock begin
                            if !s.fancyprint && isempty(s.pkg_queue) && BG.monitoring
                                printpkgstyle(s.logio, :Precompiling, s.target)
                                BG.verbose && println(s.logio, format_verbose_timing_header())
                            end
                            push!(s.pkg_queue, pkg_config)
                        end
                        mark_started!(job)
                        s.fancyprint && notify(s.first_started)
                        if should_stop(s)
                            return
                        end
                        loadable_exts = haskey(s.ext_to_parent, pkg) ? filter((dep)->haskey(s.ext_to_parent, dep), s.triggers[pkg]) : nothing

                        flags_ = if !isempty(deps)
                            `$flags --compiled-modules=strict`
                        else
                            flags
                        end

                        pid_ch = Channel{Int32}(1)
                        if from_loading && pkg in requested_pkgids
                            Base.errormonitor(Threads.@spawn :samepool begin
                                pid = try; take!(pid_ch); catch; Int32(0); end
                                pid > 0 && @lock s.print_lock set_pid!(job, pid)
                            end)
                            t = @elapsed ret = begin
                                Base.compilecache(pkg, sourcespec, std_pipe, std_pipe, !s.ignore_loaded;
                                                  flags=flags_, cacheflags, loadable_exts, signal_channel=make_signal_channel(),
                                                  pid_channel=pid_ch, report_timing=true)
                            end
                        else
                            fullname = full_name(s.ext_to_parent, pkg)
                            Base.errormonitor(Threads.@spawn :samepool begin
                                pid = try; take!(pid_ch); catch; Int32(0); end
                                pid > 0 && @lock s.print_lock set_pid!(job, pid)
                            end)
                            t = @elapsed ret = precompile_pkgs_maybe_cachefile_lock(s, pkg_config, job, fullname) do
                                if should_stop(s)
                                    return ErrorException("canceled")
                                end
                                local cachepaths = Base.find_all_in_cache_path(pkg)
                                local freshpath = @lock s.cache_lock Base.compilecache_freshest_path(pkg; ignore_loaded=s.ignore_loaded,
                                    stale_cache=s.stale_cache, cachepath_cache=s.cachepath_cache, cachepaths, sourcespec, flags=cacheflags)
                                local is_stale = freshpath === nothing
                                if !is_stale
                                    push!(freshpaths, freshpath)
                                    return nothing
                                end
                                s.logcalls === CoreLogging.Debug && @lock s.print_lock begin
                                    @debug "Precompiling $(repr("text/plain", pkg))"
                                end
                                Base.compilecache(pkg, sourcespec, std_pipe, std_pipe, !s.ignore_loaded;
                                                  flags=flags_, cacheflags, loadable_exts, signal_channel=make_signal_channel(),
                                                  pid_channel=pid_ch, report_timing=true)
                            end
                        end
                        if ret isa Exception
                            mark_soft_error!(job)
                            !s.fancyprint && BG.monitoring && @lock s.print_lock begin
                                println(s.logio, timing_string(t), color_string("  ? ", Base.warn_color(), s.hascolor), name)
                            end
                        else
                            cache_bytes = 0
                            if ret !== nothing
                                cf_jl, cf_so = ret::Tuple{String, Union{Nothing, String}}
                                cache_bytes = _precompile_cache_bytes(cf_jl, cf_so)
                            end
                            !s.fancyprint && BG.monitoring && @lock s.print_lock begin
                                verbose_prefix = BG.verbose ? format_verbose_timing(job.verbose_timing, t, cache_bytes, job.peak_rss_bytes, s.hascolor) : ""
                                println(s.logio, timing_string(t), verbose_prefix, color_string("  ✓ ", loaded ? Base.warn_color() : :green, s.hascolor), name)
                            end
                            if ret !== nothing
                                mark_recompiled!(job)
                                cachefile, _ = ret::Tuple{String, Union{Nothing, String}}
                                push!(freshpaths, cachefile)
                                build_id, _ = Base.parse_cache_buildid(cachefile)
                                stale_cache_key = (pkg, build_id, sourcespec, cachefile, s.ignore_loaded, cacheflags)::StaleCacheKey
                                @lock s.cache_lock s.stale_cache[stale_cache_key] = false
                                if loaded && Base.module_build_id(Base.loaded_modules[pkg]) != build_id
                                    @lock s.print_lock begin
                                        s.n_loaded += 1
                                        push!(s.loaded_pkgs, pkg)
                                    end
                                end
                            elseif loaded
                                # another process compiled this package; conservatively warn
                                @lock s.print_lock begin
                                    s.n_loaded += 1
                                    push!(s.loaded_pkgs, pkg)
                                end
                            end
                        end
                    catch err
                        close(std_pipe.in)
                        wait(t_monitor)
                        err isa InterruptException && rethrow()
                        # If cancel was requested, this failure is almost certainly the
                        # subprocess being SIGKILL'd by the cancel; don't report it as
                        # a precompile failure.
                        if !(@lock BG BG.cancel_requested)
                            mark_failed!(job, sprint(showerror, err))
                            !s.fancyprint && BG.monitoring && @lock s.print_lock begin
                                println(s.logio, " "^12, color_string("  ✗ ", Base.error_color(), s.hascolor), name)
                            end
                        end
                    finally
                        isopen(std_pipe.in) && close(std_pipe.in)
                        wait(t_monitor)
                        try; close(pid_ch); catch; end
                        @lock s.print_lock clear_pid!(job)
                        Base.release(s.parallel_limiter)
                    end
                else
                    if !is_stale
                        @lock s.print_lock begin
                            s.n_already_precomp += 1
                            if loaded
                                fresh_build_id, _ = Base.parse_cache_buildid(freshpath)
                                if Base.module_build_id(Base.loaded_modules[pkg]) != fresh_build_id
                                    s.n_loaded += 1
                                    push!(s.loaded_pkgs, pkg)
                                end
                            end
                        end
                    end
                end
            finally
                @lock BG @lock BG.pkg_done begin
                    n = get(BG.pending_pkgids, pkg, 0) - 1
                    if n <= 0
                        delete!(BG.pending_pkgids, pkg)
                        push!(BG.completed_pkgids, pkg)
                    else
                        BG.pending_pkgids[pkg] = n
                    end
                    notify(BG.pkg_done)
                end
                @lock s.print_lock (s.n_done += 1)
                notify(was_processed[pkg_config])
            end
            push!(batch_tasks, task)
        end
    end
    return batch_tasks
end

function drain_work_channel!(s::PrecompileSession, work_channel::Channel{PrecompileRequest})
    drainer = Threads.@spawn :samepool begin
        while true
            isopen(work_channel) || break
            should_stop(s) && break
            request = try; take!(work_channel); catch; break; end
            waiter_spawned = false
            try
                new_env = ExplicitEnv()
                req_pkgids = PkgId[]
                for name in request.pkgs
                    pkgid = Base.identify_package(name)
                    pkgid !== nothing && push!(req_pkgids, pkgid)
                end
                new_graph = build_dep_graph(new_env, request.manifest, request._from_loading, req_pkgids)
                # When no specific packages were requested, treat project deps as the requested set
                effective_pkgids = isempty(req_pkgids) ? new_graph.project_deps : req_pkgids
                @lock s.print_lock begin
                    merge!(s.direct_deps, new_graph.direct_deps)
                    merge!(s.ext_to_parent, new_graph.ext_to_parent)
                    merge!(s.parent_to_exts, new_graph.parent_to_exts)
                    merge!(s.triggers, new_graph.triggers)
                    union!(s.project_deps, new_graph.project_deps)
                    union!(s.serial_deps, new_graph.serial_deps)
                    union!(s.requested_pkgids, effective_pkgids)
                end
                new_pkg_names = copy(request.pkgs)
                new_dd = new_graph.direct_deps
                filter_dep_graph!(new_dd, new_pkg_names, new_graph.ext_to_parent, req_pkgids)
                skip_pkgs = Set{PkgId}()
                @lock BG begin
                    for pkgid in keys(new_dd)
                        if haskey(BG.pending_pkgids, pkgid)
                            push!(skip_pkgs, pkgid)
                        end
                    end
                end
                # Replace skip_pkgs with leaf nodes so circular dep detection
                # can traverse through them, but no tasks will be spawned.
                for pkgid in skip_pkgs
                    new_dd[pkgid] = PkgId[]
                end
                new_wp = Dict{PkgConfig, Base.Event}()
                for config in request.configs
                    for pkgid in keys(new_dd)
                        pkg_config = (pkgid, config)
                        if pkgid in skip_pkgs
                            # Wire existing event so new tasks wait for the already-compiling dep
                            if haskey(s.was_processed, pkg_config)
                                new_wp[pkg_config] = s.was_processed[pkg_config]
                            else
                                evt = Base.Event()
                                notify(evt)
                                new_wp[pkg_config] = evt
                            end
                        else
                            evt = Base.Event()
                            # workers read s.jobs and should_stop iterates s.was_processed under this lock
                            @lock s.print_lock begin
                                get!(PrecompileJob, s.jobs, pkg_config)
                                s.was_processed[pkg_config] = evt
                            end
                            new_wp[pkg_config] = evt
                        end
                    end
                end
                new_circular = detect_circular_deps!(new_dd, new_graph.serial_deps, new_wp, s.io, s.ext_to_parent)
                for pkgid in skip_pkgs
                    delete!(new_dd, pkgid)
                end
                if isempty(new_dd)
                    try; put!(request.result, String[]); catch; end
                    continue
                end
                @lock s.print_lock begin
                    s.n_total += length(new_dd) * length(request.configs)
                    s.n_batches += 1
                end
                new_tasks = spawn_precompile_tasks!(s;
                    direct_deps=new_dd, was_processed=new_wp, configs=request.configs,
                    circular_deps=new_circular, requested_pkgids=effective_pkgids,
                    pkg_names=request.pkgs, requested_pkgs=request.pkgs,
                    from_loading=request._from_loading)
                append!(s.injected_tasks, new_tasks)
                waiter = Threads.@spawn :samepool begin
                    try
                        waitall(new_tasks; failfast=false, throw=false)
                        # also wait for skipped packages being compiled by another request
                        foreach(wait, values(new_wp))
                        paths = @lock s.cache_lock collect(String, Iterators.flatten((v for (pkgid, v) in s.cachepath_cache if pkgid in effective_pkgids)))
                        try; put!(request.result, paths); catch; end
                    finally
                        isready(request.result) || try; put!(request.result, String[]); catch; end
                    end
                end
                push!(s.result_waiters, waiter)
                waiter_spawned = true
            catch e
                try; put!(request.result, e); catch; end
            finally
                # Only write the fallback here if no waiter was spawned — the waiter
                # has its own finally that guarantees a result is written after tasks finish.
                if !waiter_spawned && !isready(request.result)
                    try; put!(request.result, String[]); catch; end
                end
            end
        end
    end
    return drainer
end

function report_precompile_results!(s::PrecompileSession)
    if !s._from_loading
        @lock Base.require_lock begin
            Base.LOADING_CACHE[] = nothing
        end
    end
    notify(s.first_started) # in cases of no-op or !fancyprint

    quick_exit = any(t -> !istaskdone(t) || istaskfailed(t), s.tasks) || s.interrupted || s.canceled
    seconds_elapsed = round(Int, (s.time_start > 0 ? (time_ns() -% s.time_start) : 0) / 1e9)
    ndeps = count(j -> is_recompiled(j), values(s.jobs))

    requested_errs = false
    for ((dep, _), job) in s.jobs
        if is_failed(job) && dep in s.requested_pkgids
            requested_errs = true
            break
        end
    end
    if !s.strict && !requested_errs && !s.interrupted && !s.canceled
        for (_, job) in s.jobs
            is_failed(job) && clear_failure!(job)
        end
    end
    if s.canceled && !(@lock BG BG.info_requested)
        # Drop captured stdout/stderr from jobs that didn't fail before cancel,
        # since their output is just truncated cancel-induced noise. If the user
        # asked for info (SIGINFO/SIGUSR1) at any point, keep the output so the
        # profiling info gets surfaced in the cancel report. Soft errors
        # (e.g. `?` packages) are completed jobs whose captured output is the
        # actual precompile error message, so preserve it as well.
        for (_, job) in s.jobs
            (is_failed(job) || is_soft_error(job)) && continue
            job.output.size > 0 && truncate(job.output, 0)
        end
    end
    n_failed = count(j -> is_failed(j), values(s.jobs))
    if ndeps > 0 || n_failed > 0
        if !quick_exit
            logstr = sprint(context=s.logio) do iostr
                if s.fancyprint
                    what = if s.n_batches > 1
                        "done."
                    elseif isempty(s.requested_pkgids) || s.requested_all
                        "packages finished."
                    else
                        "$(join((full_name(s.ext_to_parent, p) for p in s.requested_pkgids), ", ", " and ")) finished."
                    end
                    printpkgstyle(iostr, :Precompiling, what)
                end
                plural = length(s.configs) > 1 ? "dependency configurations" : ndeps == 1 ? "dependency" : "dependencies"
                print(iostr, "  $(ndeps) $(plural) successfully precompiled in $(seconds_elapsed) seconds")
                if s.n_already_precomp > 0 || !isempty(s.circular_deps)
                    s.n_already_precomp > 0 && (print(iostr, ". $(s.n_already_precomp) already precompiled"))
                    !isempty(s.circular_deps) && (print(iostr, ". $(length(s.circular_deps)) skipped due to circular dependency"))
                    print(iostr, ".")
                end
                if s.n_loaded > 0
                    plural1 = length(s.configs) > 1 ? "dependency configurations" : s.n_loaded == 1 ? "dependency" : "dependencies"
                    plural2 = s.n_loaded == 1 ? "a different version is" : "different versions are"
                    plural3 = s.n_loaded == 1 ? "" : "s"
                    loaded_names_vec = sort!([full_name(s.ext_to_parent, p) for p in s.loaded_pkgs])
                    max_loaded_names = 5
                    if length(loaded_names_vec) > max_loaded_names
                        loaded_names = string(
                            join(first(loaded_names_vec, max_loaded_names), ", ", " and "),
                            ", and ",
                            length(loaded_names_vec) - max_loaded_names,
                            " more"
                        )
                    else
                        loaded_names = join(loaded_names_vec, ", ", " and ")
                    end
                    # compute how many precompiled packages transitively depend on the loaded packages
                    loaded_set = Set{PkgId}(s.loaded_pkgs)
                    n_affected = let reverse_deps = Dict{PkgId, Vector{PkgId}}()
                        for (p, deps) in s.direct_deps
                            for d in deps
                                push!(get!(Vector{PkgId}, reverse_deps, d), p)
                            end
                        end
                        affected = Set{PkgId}()
                        frontier = PkgId[p for p in loaded_set]
                        while !isempty(frontier)
                            p = pop!(frontier)
                            for rdep in get(reverse_deps, p, PkgId[])
                                if rdep ∉ affected && rdep ∉ loaded_set
                                    push!(affected, rdep)
                                    push!(frontier, rdep)
                                end
                            end
                        end
                        length(affected)
                    end
                    print(iostr, "\n  ",
                        color_string(string(s.n_loaded), Base.warn_color(), s.hascolor),
                        " $(plural1) precompiled but ",
                        color_string("$(plural2) currently loaded", Base.warn_color(), s.hascolor),
                        " (", loaded_names, ")",
                        ". Restart julia to access the new version$(plural3)."
                    )
                    if n_affected > 0
                        affected_plural = length(s.configs) > 1 ? "dependency configurations" : n_affected == 1 ? "dependent" : "dependents"
                        print(iostr,
                            " Otherwise, $(n_affected) $(affected_plural) of ",
                            s.n_loaded == 1 ? "this package" : "these packages",
                            " may trigger further precompilation to work with the unexpected version$(plural3)."
                        )
                    end
                end
                n_soft_errors = count(j -> is_soft_error(j), values(s.jobs))
                if n_soft_errors > 0
                    pluralpc = length(s.configs) > 1 ? "dependency configurations" : n_soft_errors == 1 ? "dependency" : "dependencies"
                    print(iostr, "\n  ",
                        color_string(string(n_soft_errors), Base.warn_color(), s.hascolor),
                        " $(pluralpc) failed but may be precompilable after restarting julia"
                    )
                end
            end
            @lock BG BG.result = logstr
            BG.monitoring && @lock s.print_lock begin
                println(s.logio, logstr)
            end
        elseif s.interrupted || s.canceled
            istr = sprint(context=s.logio) do iostr
                if s.fancyprint
                    printpkgstyle(iostr, :Precompiling, s.canceled && !s.interrupted ? "canceled." : "interrupted.")
                end
                # On cancel we don't mark in-flight jobs as failed (their subprocesses
                # were killed by the cancel itself), so report them as the "canceled" count.
                # Use the sticky `had_pid` flag so we count jobs that actually spawned a
                # subprocess, not ones that were just past mark_started!. Soft errors are
                # completed jobs that errored before cancel; report them separately.
                n_soft_errors = count(j -> is_soft_error(j), values(s.jobs))
                n_canceled_i = s.canceled && !s.interrupted ?
                    count(j -> had_pid(j) && !is_recompiled(j) && !is_soft_error(j), values(s.jobs)) :
                    n_failed
                verb = s.canceled && !s.interrupted ? "canceled" : "interrupted"
                print(iostr, "  $(ndeps) dependenc$(ndeps == 1 ? "y" : "ies") precompiled, ",
                      color_string("$(n_canceled_i)", Base.error_color(), s.hascolor),
                      " $verb after $(seconds_elapsed) seconds")
                if n_soft_errors > 0
                    pluralpc = length(s.configs) > 1 ? "dependency configurations" : n_soft_errors == 1 ? "dependency" : "dependencies"
                    print(iostr, "\n  ",
                        color_string(string(n_soft_errors), Base.warn_color(), s.hascolor),
                        " $(pluralpc) failed but may be precompilable after restarting julia"
                    )
                end
            end
            @lock BG BG.result = istr
            BG.monitoring && @lock s.print_lock begin
                println(s.logio, istr)
            end
        end
    end
    if any(j -> j.output.size > 0, values(s.jobs))
        str = sprint(context=s.io) do iostr
            let std_outputs = Tuple{PkgConfig,SubString{String}}[(pc, strip(String(take!(job.output)))) for (pc, job) in s.jobs]
                filter!(!isempty∘last, std_outputs)
                if !isempty(std_outputs)
                    plural1 = length(std_outputs) == 1 ? "y" : "ies"
                    print(iostr, "  ", color_string("$(length(std_outputs))", Base.warn_color(), s.hascolor), " dependenc$(plural1) had output during precompilation:")
                    for (pkg_config, err) in std_outputs
                        pkg, _ = pkg_config
                        err = if pkg == s.pkg_liveprinted
                            "[Output was shown above]"
                        else
                            join(split(err, "\n"), color_string("\n│  ", Base.warn_color(), s.hascolor))
                        end
                        name = full_name(s.ext_to_parent, pkg)
                        print(iostr, color_string("\n┌ ", Base.warn_color(), s.hascolor), name, color_string("\n│  ", Base.warn_color(), s.hascolor), err, color_string("\n└  ", Base.warn_color(), s.hascolor))
                    end
                end
            end
        end
        !isempty(str) && BG.monitoring && @lock s.print_lock begin
            println(s.io, str)
        end
    end
    if n_failed > 0
        err_str = IOBuffer()
        for ((dep, config), job) in s.jobs
            is_failed(job) || continue
            write(err_str, "\n")
            print(err_str, "\n", full_name(s.ext_to_parent, dep), " ")
            join(err_str, config[1], " ")
            print(err_str, "\n", job.error_msg)
        end
        pluraled = n_failed == 1 ? "" : "s"
        err_msg = "The following $n_failed package$(pluraled) failed to precompile:$(String(take!(err_str)))\n"
        if s.internal_call
            print(s.io, err_msg)
        else
            throw(PkgPrecompileError(err_msg))
        end
    end
    if s.interrupted
        throw(InterruptException())
    end
    return @lock s.cache_lock collect(String, Iterators.flatten((v for (pkgid, v) in s.cachepath_cache if pkgid in s.requested_pkgids)))
end

# The actual precompilation implementation (mode-agnostic)
function do_precompile(pkgs::Union{Vector{String}, Vector{PkgId}},
                        internal_call::Bool,
                        strict::Bool,
                        warn_loaded::Bool,
                        timing::Bool,
                        _from_loading::Bool,
                        configs::Vector{Config},
                        io::IOContext,
                        fancyprint′::Bool,
                        manifest::Bool,
                        ignore_loaded::Bool,
                        detachable::Bool,
                        work_channel::Channel{PrecompileRequest})
    requested_pkgs = copy(pkgs)
    pkg_names = pkgs isa Vector{String} ? copy(pkgs) : String[pkg.name for pkg in pkgs]
    if pkgs isa Vector{PkgId}
        requested_pkgids = copy(pkgs)
    else
        requested_pkgids = PkgId[]
        for name in pkgs
            pkgid = Base.identify_package(name)
            if pkgid === nothing
                _from_loading && return
                throw(PkgPrecompileError("Unknown package: $name"))
            end
            push!(requested_pkgids, pkgid)
        end
    end

    time_start = time_ns()
    env = ExplicitEnv()

    # Windows sometimes hits a ReadOnlyMemoryError, so we halve the default number of tasks. Pkg.jl#2323
    # TODO: Investigate why this happens in windows and restore the full task limit
    default_num_tasks = Sys.iswindows() ? div(Sys.EFFECTIVE_CPU_THREADS::Int, 2) + 1 : Sys.EFFECTIVE_CPU_THREADS::Int + 1
    default_num_tasks = min(default_num_tasks, 16) # limit for better stability on shared resource systems
    num_tasks = max(1, something(tryparse(Int, get(ENV, "JULIA_NUM_PRECOMPILE_TASKS", string(default_num_tasks))), 1))

    # Suppress precompilation progress messages when precompiling for loading packages, except during
    # interactive sessions, since the complicated IO can have disastrous consequences in the background (#59599)
    logio = io
    logcalls = nothing
    if _from_loading
        if isinteractive()
            logcalls = CoreLogging.Info
        else
            logio = IOContext{IO}(devnull)
            fancyprint′ = false
            logcalls = CoreLogging.Debug
        end
    end
    fancyprint = fancyprint′
    hascolor = get(logio, :color, false)::Bool

    # Build dependency graph
    graph = build_dep_graph(env, manifest, _from_loading, requested_pkgids)

    # Return early if no deps
    if isempty(graph.direct_deps)
        isempty(pkgs) && return
        error("No direct dependencies outside of the sysimage found matching $(pkgs)")
    end

    # Initialize signalling
    was_processed = Dict{PkgConfig,Base.Event}()
    jobs = Dict{PkgConfig,PrecompileJob}()
    for config in configs
        for pkgid in keys(graph.direct_deps)
            pkg_config = (pkgid, config)
            jobs[pkg_config] = PrecompileJob()
            was_processed[pkg_config] = Base.Event()
        end
    end

    circular_deps = detect_circular_deps!(graph.direct_deps, graph.serial_deps, was_processed, io, graph.ext_to_parent)

    if filter_dep_graph!(graph.direct_deps, pkg_names, graph.ext_to_parent, requested_pkgids)
        @lock BG BG.result = ""
        return
    end

    # When no specific packages were requested, treat project deps as the requested
    # set so direct-dep failures are reported and the returned paths cover the project.
    requested_all = isempty(requested_pkgids)
    requested_all && append!(requested_pkgids, graph.project_deps)

    nconfigs = length(configs)
    target = if nconfigs == 1
        flags = only(configs)[1]
        isempty(flags) ? "project..." : "for configuration $(join(flags, " "))"
    else
        "for $nconfigs compilation configurations"
    end

    print_lock = io.io isa Base.LibuvStream ? io.io.lock::ReentrantLock : ReentrantLock()

    # Size the shared CPU-thread budget for the parallel workers. Defaults to
    # `EFFECTIVE_CPU_THREADS + 1` (one baseline per worker plus a spare so a lone
    # worker can fill every core during imaging); `JULIA_PRECOMPILE_THREADS`
    # overrides it. Skipped for a single task or when JULIA_IMAGE_THREADS pins a
    # per-worker count (a hard override that bypasses the shared budget), unless
    # `JULIA_PRECOMPILE_THREADS` is also set, in which case it takes precedence.
    # Examples:
    #   neither set                                      -> jobserver budget = EFFECTIVE_CPU_THREADS + 1
    #   JULIA_PRECOMPILE_THREADS=8                       -> jobserver budget = 8
    #   JULIA_IMAGE_THREADS=4                            -> no jobserver; each worker pinned to 4 threads
    #   JULIA_IMAGE_THREADS=4 JULIA_PRECOMPILE_THREADS=8 -> jobserver budget = 8 (JULIA_IMAGE_THREADS ignored here)
    precompile_jobserver = if num_tasks > 1 && (haskey(ENV, "JULIA_PRECOMPILE_THREADS") || !haskey(ENV, "JULIA_IMAGE_THREADS"))
        default_budget = Sys.EFFECTIVE_CPU_THREADS + 1
        budget = max(1, something(tryparse(Int, get(ENV, "JULIA_PRECOMPILE_THREADS", string(default_budget))), default_budget))
        setup_precompile_jobserver!(budget)
    else
        :none
    end

    s = PrecompileSession(;
        configs, io, logio, logcalls, fancyprint, hascolor,
        warn_loaded, ignore_loaded, internal_call, strict, _from_loading,
        time_start, print_lock,
        parallel_limiter=WorkerLimiter(Base.Semaphore(num_tasks), precompile_jobserver !== :none), num_tasks,
        start_loaded_modules=Set{PkgId}(keys(Base.loaded_modules)), requested_pkgids, requested_all,
        direct_deps=graph.direct_deps,
        ext_to_parent=graph.ext_to_parent, parent_to_exts=graph.parent_to_exts,
        triggers=graph.triggers, project_deps=graph.project_deps,
        serial_deps=graph.serial_deps, circular_deps,
        n_total=length(graph.direct_deps) * nconfigs,
        printloop_should_exit=!fancyprint, target,
        jobs, was_processed,
    )

    # Start print loop
    t_print = spawn_print_loop!(s)
    # In non-fancy mode the print loop exits immediately and so `poll_process_stats!`
    # never runs; sample peak RSS on a timer instead for the `~pk-rss` column.
    # Gated per tick since verbose can be enabled mid-run (injected request, `v` key).
    # Locks taken sequentially: the print loop nests BG inside print_lock.
    peak_rss_timer = if !fancyprint
        Timer(0.1; interval=0.5, spawn=true) do _
            verbose_now = @lock BG BG.verbose
            verbose_now && @lock s.print_lock sample_peak_rss!(s.jobs)
        end
    else
        nothing
    end

    try
        if !_from_loading
            @lock Base.require_lock begin
                Base.LOADING_CACHE[] = Base.LoadingCache()
            end
        end
        @debug "precompile: starting precompilation loop" graph.direct_deps graph.project_deps

        # Initial pass
        initial_tasks = spawn_precompile_tasks!(s;
            direct_deps=graph.direct_deps, was_processed, configs, circular_deps,
            requested_pkgids, pkg_names, requested_pkgs, from_loading=_from_loading)
        append!(s.tasks, initial_tasks)

        # Concurrent drainer for injected requests
        drainer = drain_work_channel!(s, work_channel)

        waitall(initial_tasks; failfast=false, throw=false)
        @lock BG close(work_channel)
        wait(drainer)
        append!(s.tasks, s.injected_tasks)
        waitall(s.injected_tasks; failfast=false, throw=false)
        waitall(s.result_waiters; failfast=false, throw=false)

        # Final output
        s.interrupted_or_done = true
        notify(s.first_started) # unblock print loop if nothing ever started (no-op case)
        fancyprint && wait(t_print)
        return report_precompile_results!(s)
    finally
        # Ensure print loop exits even on exception
        s.interrupted_or_done = true
        notify(s.first_started)
        peak_rss_timer === nothing || close(peak_rss_timer)
        precompile_jobserver === :created && teardown_precompile_jobserver!()
    end
end

end
