diff --git a/Project.toml b/Project.toml index ce740e8..5e218c5 100644 --- a/Project.toml +++ b/Project.toml @@ -6,7 +6,7 @@ version = "1.0.0" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" [compat] -julia = "1.6" +julia = "1.10" Random = "1.6" [extras] diff --git a/src/base58.jl b/src/base58.jl index 62f4e8b..1bf7164 100644 --- a/src/base58.jl +++ b/src/base58.jl @@ -27,7 +27,9 @@ function encode(input::AbstractVector{UInt8})::String # Convert to base 58 string if num == 0 - return "1" ^ zero_count + # explicit byte fill, not "1"^n: repeat(::String, ::Int) doesn't resolve + # under `juliac --trim` + return String(fill(UInt8('1'), zero_count)) end result = "" @@ -36,8 +38,8 @@ function encode(input::AbstractVector{UInt8})::String result = ALPHABET[remainder + 1] * result end - # Add leading zeros - return "1" ^ zero_count * result + # Add leading zeros (byte fill, not "1"^n — see above) + return String(fill(UInt8('1'), zero_count)) * result end function decode(input::String)::Vector{UInt8} diff --git a/src/uid.jl b/src/uid.jl index f30c5ce..a87ce3f 100644 --- a/src/uid.jl +++ b/src/uid.jl @@ -171,8 +171,56 @@ struct UID{T, U <: Union{UID2, UID4, UID8, UID16, UID24, UID32, UID64}} uid::U end -# Constructor that takes any number of arguments and encodes them -function UID(args...; uid_type::Type{<:Union{UID2, UID4, UID8, UID16, UID24, UID32, UID64}} = UID8) +# Constructor that takes any number of arguments and encodes them. +# Both entry points forward to _uid_impl with the uid type as a positional where-param: +# julia doesn't specialize methods on unparameterized Type arguments, and kwarg +# NamedTuples type a Type value as a bare DataType — either way every call funneled +# into one instance where `uid_type` is a runtime DataType: type-unstable, and +# unresolvable dynamic dispatch under `juliac --trim`. +UID(args...; uid_type::Type{U} = UID8) where {U <: Union{UID2, UID4, UID8, UID16, UID24, UID32, UID64}} = + _uid_impl(U, args...) + +# Constructor with specific UID type +UID(uid_type::Type{U}, args...) where {U <: Union{UID2, UID4, UID8, UID16, UID24, UID32, UID64}} = + _uid_impl(U, args...) + +# Value-level fast path for the common "encode one string into a single-word uid" +# case (e.g. prefixed ids). The generic path wraps the string as StringN{length(s)} — +# a runtime type parameter — which makes the whole encode machinery runtime dispatch: +# type-unstable, and unresolvable under `juliac --trim`. This produces bit-identical +# results (verified against the generic path) using the length's value instead of its +# type. Multi-word uid types fall through to the generic `args...` method. +# NOTE: the result is tagged UID{String, U} (not UID{Tuple{StringN{N}}, U}); +# generation/printing is identical, but callers that decode by type should use the +# generic constructor. +function UID(uid_type::Type{U}, s::String) where {U <: Union{UID2, UID4, UID8, UID16}} + n = length(s) + total_bits = n * 8 + available_bits = bitsize(U) + if total_bits > available_bits + throw(ArgumentError("Need $total_bits bits but only have $available_bits available in $U")) + end + # random init, then overwrite the low n*8 bits with the packed characters — + # the same layout encode_multi_word produces for a single StringN argument + w = _uid_word(U()) + for i in 1:n + off = (i - 1) * 8 + w = (w & ~(UInt128(0xff) << off)) | (UInt128(UInt8(s[i])) << off) + end + return UID{String, U}(_uid_from_word(U, w)) +end + +_uid_word(u::UID2) = UInt128(UInt16(u)) +_uid_word(u::UID4) = UInt128(UInt32(u)) +_uid_word(u::UID8) = UInt128(UInt64(u)) +_uid_word(u::UID16) = UInt128(u) + +_uid_from_word(::Type{UID2}, w::UInt128) = UID2(UInt16(w)) +_uid_from_word(::Type{UID4}, w::UInt128) = UID4(UInt32(w)) +_uid_from_word(::Type{UID8}, w::UInt128) = UID8(UInt64(w)) +_uid_from_word(::Type{UID16}, w::UInt128) = UID16(w) + +function _uid_impl(uid_type::Type{U}, args...) where {U <: Union{UID2, UID4, UID8, UID16, UID24, UID32, UID64}} if isempty(args) return UID{Nothing, uid_type}(uid_type()) end @@ -213,11 +261,6 @@ function UID(args...; uid_type::Type{<:Union{UID2, UID4, UID8, UID16, UID24, UID return UID{Tuple{map(typeof, wrapped_args)...}, uid_type}(uid) end -# Constructor with specific UID type -function UID(uid_type::Type{<:Union{UID2, UID4, UID8, UID16, UID24, UID32, UID64}}, args...) - return UID(args...; uid_type=uid_type) -end - # String representation function Base.string(uid::UID) return string(uid.uid) diff --git a/test/encid_trim_workload.jl b/test/encid_trim_workload.jl new file mode 100644 index 0000000..433b499 --- /dev/null +++ b/test/encid_trim_workload.jl @@ -0,0 +1,84 @@ +# Trim-compile workload: exercises the code paths the trim-verifier fixes +# cover, with value-level assertions so the compiled executable proves runtime +# behavior too. Compiled by test/trim_compile_tests.jl with `juliac --trim=safe` +# and an error budget of zero, then executed. +using Encid + +# base58 leading-zero padding (the repeat(::String, n) fix): leading zero bytes +# must become leading '1's, and the round trip must be exact +function _assert_base58()::Nothing + Encid.Base58.encode(UInt8[0x00, 0x00, 0x01]) == "112" || error("base58 leading zeros") + Encid.Base58.encode(UInt8[0x00, 0x00, 0x00]) == "111" || error("base58 all zeros") + for bytes in (UInt8[0x00, 0x00, 0x01], UInt8[0x00, 0xff, 0x00, 0x2a], UInt8[0x2a]) + Encid.Base58.decode(Encid.Base58.encode(bytes)) == bytes || error("base58 roundtrip") + end + return nothing +end + +# primitive uid construction, string rendering, and value conversions +function _assert_primitive_uids()::Nothing + string(UID8(UInt64(1))) == "Ahg1opVcGX" || error("UID8 string") + UInt64(UID8(UInt64(42))) == UInt64(42) || error("UID8 word roundtrip") + r = UID8() + length(string(r)) > 0 || error("UID8 random string") + bits_required(1000) == 10 || error("bits_required") + bitsize(UID8) == 64 || error("bitsize") + Encid.decode_value(Int, Encid.encode_value(42, 16), 16) == 42 || error("encode/decode_value") + return nothing +end + +# the single-word String fast path (value-level, no StringN{N} runtime type +# parameter): result is tagged UID{String, U} and packs characters into the +# low bits exactly like the generic path +function _assert_string_fast_path()::Nothing + u = UID(UID8, "EVT") + u isa UID{String, UID8} || error("fast path type") + expected = (UInt64('T') << 16) | (UInt64('V') << 8) | UInt64('E') + (UInt64(u.uid) & 0x0000000000ffffff) == expected || error("fast path packing") + length(string(u)) > 0 || error("fast path string") + u == u || error("fast path equality") + hash(u) isa UInt || error("fast path hash") + io = IOBuffer() + show(io, u) + startswith(String(take!(io)), "UID\"") || error("fast path show") + return nothing +end + +# the value-level String fast path across all single-word uid types. +# NOTE: this is the trim-safe constructor surface. The generic `args...` +# machinery (including the kwarg form) wraps strings as StringN{length(s)} — +# a runtime type parameter — and decode-by-index walks runtime type +# parameters; both remain dynamic and are exercised by the regular test +# suite instead. +function _check_fast_path(u::UID{String, U})::Nothing where {U} + length(string(u)) > 0 || error("ctor string") + u == u || error("ctor equality") + hash(u) isa UInt || error("ctor hash") + return nothing +end + +function _assert_fast_path_ctors()::Nothing + _check_fast_path(UID(UID2, "a")) + _check_fast_path(UID(UID4, "ab")) + _check_fast_path(UID(UID8, "PAY")) + u16 = UID(UID16, "EVENT") + u16 isa UID{String, UID16} || error("UID16 fast path type") + _check_fast_path(u16) + return nothing +end + +function run_encid_trim_workload()::Nothing + _assert_base58() + _assert_primitive_uids() + _assert_string_fast_path() + _assert_fast_path_ctors() + return nothing +end + +function @main(args::Vector{String})::Cint + _ = args + run_encid_trim_workload() + return 0 +end + +Base.Experimental.entrypoint(main, (Vector{String},)) diff --git a/test/runtests.jl b/test/runtests.jl index 3f4e3fa..a0b9f7a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -28,3 +28,5 @@ end encoded = Encid.Base58.encode(bytes) @test Encid.Base58.decode(encoded) == bytes end + +include("trim_compile_tests.jl") diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl new file mode 100644 index 0000000..3976e4b --- /dev/null +++ b/test/trim_compile_tests.jl @@ -0,0 +1,180 @@ +using Test + +const _TRIM_SAFE_ERROR_BUDGET = 0 +const _TRIM_SUPPORTED = VERSION >= v"1.12.0-rc1" +const _TRIM_PRE_RELEASE = !isempty(VERSION.prerelease) +const _TRIM_COMPILE_TIMEOUT_S = Sys.iswindows() ? 600.0 : 120.0 +const _TRIM_EXECUTABLE_TIMEOUT_S = Sys.iswindows() ? 120.0 : 30.0 +const _TRIM_USE_BUNDLE = Sys.iswindows() +const _JULIAC_ENTRYPOINT_EXPR = "using JuliaC; if isdefined(JuliaC, :main); JuliaC.main(ARGS); else JuliaC._main_cli(ARGS); end" + +# Pkg.test() sets JULIA_LOAD_PATH restrictively, which prevents subprocesses +# from finding stdlib packages like Pkg. Remove it so subprocesses get the +# default load path. +function _clean_cmd(cmd::Cmd) + env = Dict{String,String}(k => v for (k, v) in ENV if k != "JULIA_LOAD_PATH") + return setenv(cmd, env) +end + +function _setup_trim_env() + # JuliaC requires Julia 1.12+ and can't be in [extras] without breaking + # Pkg.test() on older Julia versions. Create a temp project that dev's + # Encid from the local checkout and adds JuliaC. + pkg_path = normpath(joinpath(@__DIR__, "..")) + env_path = mktempdir() + julia = joinpath(Sys.BINDIR, Base.julia_exename()) + setup_script = joinpath(env_path, "setup.jl") + write(setup_script, """ + import Pkg + Pkg.develop(path=$(repr(pkg_path))) + Pkg.add("JuliaC") + """) + println("[trim] setting up temp environment with JuliaC...") + flush(stdout) + exit_code, output, timed_out = _run_command_with_timeout( + _clean_cmd(`$julia --startup-file=no --history-file=no --project=$env_path $setup_script`); + timeout_s = 120.0, log_label = "setup" + ) + rm(setup_script; force = true) + if exit_code != 0 || timed_out + println("[trim] setup FAILED (exit=$exit_code, timed_out=$timed_out)") + println(output) + error("failed to set up trim test environment") + end + println("[trim] temp environment ready") + return env_path +end + +function _run_trim_compile(project_path::String, script_path::String, output_name::String; timeout_s::Float64 = _TRIM_COMPILE_TIMEOUT_S, bundle_dir::Union{Nothing, String} = nothing) + julia_exe = joinpath(Sys.BINDIR, Base.julia_exename()) + cmd = if bundle_dir === nothing + _clean_cmd(`$julia_exe --startup-file=no --history-file=no --code-coverage=none --project=$project_path -e $(_JULIAC_ENTRYPOINT_EXPR) -- --output-exe $output_name --project=$project_path --experimental --trim=safe $script_path`) + else + _clean_cmd(`$julia_exe --startup-file=no --history-file=no --code-coverage=none --project=$project_path -e $(_JULIAC_ENTRYPOINT_EXPR) -- --output-exe $output_name --bundle $bundle_dir --project=$project_path --experimental --trim=safe $script_path`) + end + return _run_command_with_timeout(cmd; timeout_s = timeout_s, log_label = "compile") +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 = timeout_s, log_label = 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) + started_at = time() + next_log_at = started_at + 10.0 + timed_out = false + while Base.process_running(proc) + now = time() + if now - started_at >= timeout_s + timed_out = true + try; kill(proc); catch; end + break + 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 timed_out +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 _run_trim_case(project_path::String, script_file::String, output_name::String) + 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 + bundle_dir = _TRIM_USE_BUNDLE ? joinpath(tmpdir, "bundle") : nothing + exit_code, output, timed_out = _run_trim_compile(project_path, script_path, output_name; bundle_dir = bundle_dir) + if timed_out + println("[trim] compile TIMED OUT for $(script_file)") + println(output) + @test false + return + end + totals = _parse_trim_verify_totals(output) + trim_errors, trim_warnings = if totals === nothing + exit_code == 0 ? (0, 0) : error("failed to parse trim verifier summary:\n$output") + else + totals + end + if trim_errors > 0 || trim_warnings > 0 + println("---- trim compile output ($(script_file)) ----") + println(output) + println("---- end output ----") + end + @test trim_errors <= _TRIM_SAFE_ERROR_BUDGET + @test trim_warnings == 0 + output_path = Sys.iswindows() ? "$(output_name).exe" : output_name + if trim_errors == 0 + run_path = bundle_dir === nothing ? output_path : joinpath(bundle_dir, "bin", output_path) + @test exit_code == 0 + @test isfile(run_path) + run_cmd = `$(abspath(run_path))` + run_exit, run_output, run_timed_out = _run_command_with_timeout(run_cmd; timeout_s = _TRIM_EXECUTABLE_TIMEOUT_S, log_label = "run") + if run_timed_out + println("[trim] executable TIMED OUT for $(script_file)") + println(run_output) + end + if run_exit != 0 + println("---- trim executable output ($(script_file)) ----") + println(run_output) + println("---- end output ----") + end + @test !run_timed_out + @test run_exit == 0 + else + @test exit_code != 0 + end + end + end + println("[trim] compile DONE $(script_file) ($(round(time() - start_t; digits = 2))s)") + return nothing +end + +@testset "Trim compile" begin + if !_TRIM_SUPPORTED + println("[trim] skip Julia < 1.12: JuliaC trim compilation is unavailable") + @test true + elseif _TRIM_PRE_RELEASE + println("[trim] skip prerelease Julia: trim verifier behavior is not stable yet") + @test true + else + project_path = _setup_trim_env() + trim_workloads = [ + ("encid_trim_workload.jl", "encid_trim_workload"), + ] + for (script_file, output_name) in trim_workloads + _run_trim_case(project_path, script_file, output_name) + end + end +end