diff --git a/Project.toml b/Project.toml index 6d60525..1c3736d 100644 --- a/Project.toml +++ b/Project.toml @@ -28,8 +28,9 @@ julia = "1.9" [extras] Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45" ArrowTypes = "31f734f8-188a-4ce0-8406-c8a06bd891cd" +Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" Tar = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Arrow", "Tar", "Test"] +test = ["Arrow", "Pkg", "Tar", "Test"] diff --git a/test/json_trim_public_entrypoints.jl b/test/json_trim_public_entrypoints.jl new file mode 100644 index 0000000..61022fd --- /dev/null +++ b/test/json_trim_public_entrypoints.jl @@ -0,0 +1,72 @@ +using JSON + +const ARRAY_JSON = "[1,2,3]" +const STRING_JSON = "\"Ada\"" +const TRIM_FIXTURE_DIR = joinpath(@__DIR__, "trim") + +JSON.@nonstruct struct TrimCode + value::String +end + +JSON.lower(x::TrimCode) = x.value + +function checked(cond::Bool, msg::String)::Nothing + cond || error(msg) + return nothing +end + +function exercise_lazy_entrypoints()::Nothing + checked(JSON.lazy(ARRAY_JSON) isa JSON.LazyValue, "lazy failed") + checked(JSON.lazyfile(joinpath(TRIM_FIXTURE_DIR, "value.json")) isa JSON.LazyValue, "lazyfile failed") + return nothing +end + +function exercise_parse_entrypoints()::Nothing + # Materializing JSON.parse currently pulls in verifier failures in parser + # and StructUtils error paths, so keep this read workload to trim-safe + # lazy entrypoints until those verifier issues can be chased down. + checked(JSON.lazy("7") isa JSON.LazyValue, "numeric lazy detection failed") + checked(JSON.lazy(IOBuffer(STRING_JSON)) isa JSON.LazyValue, "IO lazy detection failed") + return nothing +end + +function exercise_write_entrypoints()::Nothing + obj = JSON.Object{String, Int}("score" => 7) + obj[:score] = 10 + checked(obj.score == 10, "Object property access failed") + checked(haskey(obj, "score"), "Object setindex! failed") + delete!(obj, :score) + checked(!haskey(obj, "score"), "Object delete! failed") + + checked(JSON.json([1, 2, 3]) == ARRAY_JSON, "json string output failed") + + io = IOBuffer() + JSON.json(io, [1, 2, 3]; pretty = 2) + checked(String(take!(io)) == "[\n 1,\n 2,\n 3\n]", "pretty IO json output failed") + + print_io = IOBuffer() + JSON.print(print_io, [1, 2, 3], 2) + checked(String(take!(print_io)) == "[\n 1,\n 2,\n 3\n]", "JSON.print failed") + + jsonlines = JSON.json([[1], [2]]; jsonlines = true) + checked(jsonlines == "[1]\n[2]\n", "jsonlines write failed") + checked(JSON.json(JSON.JSONText("{\"raw\":true}")) == "{\"raw\":true}", "JSONText write failed") + checked(JSON.json(JSON.Null()) == "null", "JSON.Null write failed") + checked(JSON.json(TrimCode("beta")) == "\"beta\"", "custom lower write failed") + return nothing +end + +function run_json_trim_public_entrypoints()::Nothing + exercise_lazy_entrypoints() + exercise_parse_entrypoints() + exercise_write_entrypoints() + return nothing +end + +function @main(args::Vector{String})::Cint + _ = args + run_json_trim_public_entrypoints() + return 0 +end + +Base.Experimental.entrypoint(main, (Vector{String},)) diff --git a/test/runtests.jl b/test/runtests.jl index ca1498a..2c33ca9 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,6 +8,7 @@ include(joinpath(dirname(pathof(JSON)), "../test/json.jl")) if Sys.WORD_SIZE == 64 include(joinpath(dirname(pathof(JSON)), "../test/arrow.jl")) end +include(joinpath(dirname(pathof(JSON)), "../test/trim_compile_tests.jl")) function tar_files(tarball::String) data = Dict{String, Vector{UInt8}}() diff --git a/test/trim/Project.toml b/test/trim/Project.toml new file mode 100644 index 0000000..bdf83a4 --- /dev/null +++ b/test/trim/Project.toml @@ -0,0 +1,2 @@ +[deps] +JuliaC = "acedd4c2-ced6-4a15-accc-2607eb759ba2" diff --git a/test/trim/value.json b/test/trim/value.json new file mode 100644 index 0000000..610e2c4 --- /dev/null +++ b/test/trim/value.json @@ -0,0 +1 @@ +"Ada" diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl new file mode 100644 index 0000000..8caddc0 --- /dev/null +++ b/test/trim_compile_tests.jl @@ -0,0 +1,175 @@ +using Test +using JSON +import Pkg + +const _TRIM_SUPPORTED = VERSION >= v"1.12.0-rc1" +const _JULIAC_ENTRYPOINT_EXPR = "using JuliaC; if isdefined(JuliaC, :main); JuliaC.main(ARGS); else JuliaC._main_cli(ARGS); end" +const _TRIM_COMPILE_TIMEOUT_S = 300.0 +const _TRIM_RUN_TIMEOUT_S = 60.0 + +function _json_project_path()::String + return normpath(joinpath(dirname(pathof(JSON)), "..")) +end + +function _prepare_trim_project(project_path::String, trim_project::String)::Nothing + mkpath(trim_project) + cp(joinpath(@__DIR__, "trim", "Project.toml"), joinpath(trim_project, "Project.toml")) + original_project = Base.active_project() + try + Pkg.activate(trim_project) + Pkg.develop(Pkg.PackageSpec(path = project_path)) + Pkg.instantiate() + finally + if original_project !== nothing + Pkg.activate(dirname(original_project)) + end + end + return nothing +end + +function _run_command_with_timeout(cmd::Cmd; timeout_s::Float64, log_label::String) + output_path = tempname() + out = open(output_path, "w") + exit_code = -1 + timed_out = false + try + proc = run(pipeline(ignorestatus(cmd), stdout = out, stderr = out); wait = false) + timed_out = _wait_process_with_timeout!(proc; timeout_s, log_label) + exit_code = something(proc.exitcode, -1) + finally + close(out) + end + output = try + read(output_path, String) + catch + "" + finally + rm(output_path; force = true) + end + return exit_code, output, timed_out +end + +function _wait_process_with_timeout!(proc::Base.Process; timeout_s::Float64, log_label::String)::Bool + started_at = time() + next_log_at = started_at + 10.0 + while Base.process_running(proc) + now = time() + if now - started_at >= timeout_s + try + kill(proc) + catch + end + return true + end + if now >= next_log_at + elapsed = round(now - started_at; digits = 1) + println("[trim] $(log_label) WAIT $(elapsed)s") + flush(stdout) + next_log_at = now + 10.0 + end + sleep(0.1) + end + try + wait(proc) + catch + end + return false +end + +function _trim_timeout_error(kind::String, script_file::String, output::String = "") + msg = "trim $(kind) timed out for $(script_file)" + if !isempty(output) + msg = string(msg, "\n---- captured output ----\n", output, "\n---- end captured output ----") + end + throw(ArgumentError(msg)) +end + +function _maybe_print_output(header::String, output::String)::Nothing + isempty(output) && return nothing + println(header) + println(output) + println("---- end output ----") + return nothing +end + +function _run_trim_compile(trim_project::String, script_path::String, output_name::String) + julia_exe = joinpath(Sys.BINDIR, Base.julia_exename()) + cmd = `$julia_exe --startup-file=no --history-file=no --code-coverage=none --project=$trim_project -e $(_JULIAC_ENTRYPOINT_EXPR) -- --output-exe $output_name --project=$trim_project --experimental --trim=safe $script_path` + return _run_command_with_timeout(cmd; timeout_s = _TRIM_COMPILE_TIMEOUT_S, log_label = "compile") +end + +function _run_trim_executable(run_path::String) + return _run_command_with_timeout(`$(abspath(run_path))`; timeout_s = _TRIM_RUN_TIMEOUT_S, log_label = "run") +end + +function _parse_trim_verify_totals(output::String) + m = match(r"Trim verify finished with\s+(\d+)\s+errors,\s+(\d+)\s+warnings\.", output) + m === nothing && return nothing + return parse(Int, m.captures[1]), parse(Int, m.captures[2]) +end + +function _count_trim_verify_messages(output::String)::Tuple{Int, Int} + errors = length(collect(eachmatch(r"Verifier error #\d+:", output))) + warnings = length(collect(eachmatch(r"Verifier warning #\d+:", output))) + return errors, warnings +end + +function _run_trim_case(trim_project::String, script_file::String, output_name::String)::Nothing + script_path = joinpath(@__DIR__, script_file) + @test isfile(script_path) + println("[trim] compile START $(script_file)") + start_t = time() + mktempdir() do tmpdir + cd(tmpdir) do + exit_code, output, timed_out = _run_trim_compile(trim_project, script_path, output_name) + timed_out && _trim_timeout_error("compile", script_file, output) + totals = _parse_trim_verify_totals(output) + trim_errors, trim_warnings = if totals === nothing + fallback = _count_trim_verify_messages(output) + if exit_code == 0 && fallback == (0, 0) + fallback + else + error("failed to parse trim verifier summary:\n$output") + end + else + totals + end + if trim_errors > 0 || trim_warnings > 0 + _maybe_print_output("---- trim compile output ($(script_file)) ----", output) + end + @test trim_errors == 0 + @test trim_warnings == 0 + output_path = Sys.iswindows() ? "$(output_name).exe" : output_name + @test exit_code == 0 + @test isfile(output_path) + run_exit, run_output, run_timed_out = _run_trim_executable(output_path) + run_timed_out && _trim_timeout_error("executable run", script_file, run_output) + if run_exit != 0 + _maybe_print_output("---- trim executable output ($(script_file)) ----", run_output) + end + @test run_exit == 0 + end + end + println("[trim] compile DONE $(script_file) ($(round(time() - start_t; digits = 2))s)") + return nothing +end + +@testset "Trim compile" begin + if Sys.iswindows() + println("[trim] skip Windows: JuliaC trim compilation is currently too slow or stalls on Windows CI") + @test true + elseif Sys.WORD_SIZE != 64 + println("[trim] skip 32-bit Julia: JuliaC trim compilation is only covered on 64-bit test jobs") + @test true + elseif !_TRIM_SUPPORTED + println("[trim] skip Julia < 1.12: JuliaC trim compilation is unavailable") + @test true + else + project_path = _json_project_path() + mktempdir() do tmpdir + trim_project = joinpath(tmpdir, "trim_project") + _prepare_trim_project(project_path, trim_project) + _run_trim_case(trim_project, "json_trim_public_entrypoints.jl", "json_trim_public_entrypoints") + end + end +end