diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java index dbaa5e3eec6..523b6c4821c 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -50,6 +50,7 @@ import jdk.jpackage.internal.model.LinuxLauncher; import jdk.jpackage.internal.model.LinuxPackage; import jdk.jpackage.internal.model.Package; import jdk.jpackage.internal.util.CompositeProxy; +import jdk.jpackage.internal.util.Enquoter; import jdk.jpackage.internal.util.PathUtils; import jdk.jpackage.internal.util.XmlUtils; diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LibProvidersLookup.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LibProvidersLookup.java index 55200b908cd..6faacbca528 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LibProvidersLookup.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LibProvidersLookup.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -27,13 +27,12 @@ package jdk.jpackage.internal; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import java.util.Collection; -import java.util.Objects; import java.util.Collections; -import java.util.Set; -import java.util.ArrayList; import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -48,9 +47,6 @@ public final class LibProvidersLookup { return (new ToolValidator(TOOL_LDD).validate() == null); } - public LibProvidersLookup() { - } - LibProvidersLookup setPackageLookup(PackageLookup v) { packageLookup = v; return this; @@ -87,23 +83,20 @@ public final class LibProvidersLookup { } private static List getNeededLibsForFile(Path path) throws IOException { - List result = new ArrayList<>(); - int ret = Executor.of(TOOL_LDD, path.toString()).setOutputConsumer(lines -> { - lines.map(line -> { - Matcher matcher = LIB_IN_LDD_OUTPUT_REGEX.matcher(line); - if (matcher.find()) { - return matcher.group(1); - } - return null; - }).filter(Objects::nonNull).map(Path::of).forEach(result::add); - }).execute(); + final var result = Executor.of(TOOL_LDD, path.toString()).saveOutput().execute(); - if (ret != 0) { + if (result.getExitCode() != 0) { // objdump failed. This is OK if the tool was applied to not a binary file return Collections.emptyList(); } - return result; + return result.stdout().stream().map(line -> { + Matcher matcher = LIB_IN_LDD_OUTPUT_REGEX.matcher(line); + if (matcher.find()) { + return matcher.group(1); + } + return null; + }).filter(Objects::nonNull).map(Path::of).toList(); } private static Collection getNeededLibsForFiles(List paths) { diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxBundlingEnvironment.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxBundlingEnvironment.java index 6e438e66a26..d2169ede461 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxBundlingEnvironment.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxBundlingEnvironment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -33,6 +33,7 @@ import static jdk.jpackage.internal.cli.StandardBundlingOperation.CREATE_LINUX_R import java.util.Map; import java.util.Optional; +import java.util.function.Supplier; import java.util.stream.Stream; import jdk.jpackage.internal.cli.Options; import jdk.jpackage.internal.cli.StandardBundlingOperation; @@ -44,19 +45,34 @@ import jdk.jpackage.internal.util.Result; public class LinuxBundlingEnvironment extends DefaultBundlingEnvironment { public LinuxBundlingEnvironment() { - super(build() - .defaultOperation(() -> { - return LazyLoad.SYS_ENV.value().map(LinuxSystemEnvironment::nativePackageType).map(DESCRIPTORS::get); - }) - .bundler(CREATE_LINUX_APP_IMAGE, LinuxBundlingEnvironment::createAppImage) - .bundler(CREATE_LINUX_DEB, LazyLoad::debSysEnv, LinuxBundlingEnvironment::createDebPackage) - .bundler(CREATE_LINUX_RPM, LazyLoad::rpmSysEnv, LinuxBundlingEnvironment::createRpmPackage)); + super(build().mutate(builder -> { + + // Wrap the generic Linux system environment supplier in the run-once wrapper + // as this supplier is called from both RPM and DEB Linux system environment suppliers. + var sysEnv = runOnce(() -> { + return LinuxSystemEnvironment.create(); + }); + + Supplier> debSysEnv = () -> { + return LinuxDebSystemEnvironment.create(sysEnv.get()); + }; + + Supplier> rpmSysEnv = () -> { + return LinuxRpmSystemEnvironment.create(sysEnv.get()); + }; + + builder.defaultOperation(() -> { + return sysEnv.get().value().map(LinuxSystemEnvironment::nativePackageType).map(DESCRIPTORS::get); + }) + .bundler(CREATE_LINUX_DEB, debSysEnv, LinuxBundlingEnvironment::createDebPackage) + .bundler(CREATE_LINUX_RPM, rpmSysEnv, LinuxBundlingEnvironment::createRpmPackage); + }).bundler(CREATE_LINUX_APP_IMAGE, LinuxBundlingEnvironment::createAppImage)); } private static void createDebPackage(Options options, LinuxDebSystemEnvironment sysEnv) { createNativePackage(options, - LinuxFromOptions.createLinuxDebPackage(options), + LinuxFromOptions.createLinuxDebPackage(options, sysEnv), buildEnv()::create, LinuxBundlingEnvironment::buildPipeline, (env, pkg, outputDir) -> { @@ -67,7 +83,7 @@ public class LinuxBundlingEnvironment extends DefaultBundlingEnvironment { private static void createRpmPackage(Options options, LinuxRpmSystemEnvironment sysEnv) { createNativePackage(options, - LinuxFromOptions.createLinuxRpmPackage(options), + LinuxFromOptions.createLinuxRpmPackage(options, sysEnv), buildEnv()::create, LinuxBundlingEnvironment::buildPipeline, (env, pkg, outputDir) -> { @@ -90,23 +106,6 @@ public class LinuxBundlingEnvironment extends DefaultBundlingEnvironment { return new BuildEnvFromOptions().predefinedAppImageLayout(APPLICATION_LAYOUT); } - private static final class LazyLoad { - - static Result debSysEnv() { - return DEB_SYS_ENV; - } - - static Result rpmSysEnv() { - return RPM_SYS_ENV; - } - - private static final Result SYS_ENV = LinuxSystemEnvironment.create(); - - private static final Result DEB_SYS_ENV = LinuxDebSystemEnvironment.create(SYS_ENV); - - private static final Result RPM_SYS_ENV = LinuxRpmSystemEnvironment.create(SYS_ENV); - } - private static final Map DESCRIPTORS = Stream.of( CREATE_LINUX_DEB, CREATE_LINUX_RPM diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebPackager.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebPackager.java index 64a0368e9a0..0ec6a77e683 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebPackager.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebPackager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2012, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -25,7 +25,6 @@ package jdk.jpackage.internal; -import static jdk.jpackage.internal.model.StandardPackageType.LINUX_DEB; import static jdk.jpackage.internal.util.function.ThrowingFunction.toFunction; import java.io.IOException; @@ -76,11 +75,11 @@ final class LinuxDebPackager extends LinuxPackager { try { // Try the real path first as it works better on newer Ubuntu versions - return findProvidingPackages(realPath, sysEnv.dpkg()); + return findProvidingPackages(realPath, sysEnv); } catch (IOException ex) { // Try the default path if differ if (!realPath.equals(file)) { - return findProvidingPackages(file, sysEnv.dpkg()); + return findProvidingPackages(file, sysEnv); } else { throw ex; } @@ -107,7 +106,7 @@ final class LinuxDebPackager extends LinuxPackager { properties.forEach(property -> cmdline.add(property.name)); - Map actualValues = Executor.of(cmdline.toArray(String[]::new)) + Map actualValues = Executor.of(cmdline) .saveOutput(true) .executeExpectSuccess() .getOutput().stream() @@ -158,9 +157,8 @@ final class LinuxDebPackager extends LinuxPackager { cmdline.addAll(List.of("-b", env.appImageDir().toString(), debFile.toAbsolutePath().toString())); // run dpkg - RetryExecutor.retryOnKnownErrorMessage( - "semop(1): encountered an error: Invalid argument").execute( - cmdline.toArray(String[]::new)); + Executor.of(cmdline).retryOnKnownErrorMessage( + "semop(1): encountered an error: Invalid argument").execute(); Log.verbose(I18N.format("message.output-to-location", debFile.toAbsolutePath())); } @@ -233,7 +231,7 @@ final class LinuxDebPackager extends LinuxPackager { } } - private static Stream findProvidingPackages(Path file, Path dpkg) throws IOException { + private static Stream findProvidingPackages(Path file, LinuxDebSystemEnvironment sysEnv) throws IOException { // // `dpkg -S` command does glob pattern lookup. If not the absolute path // to the file is specified it might return mltiple package names. @@ -279,9 +277,9 @@ final class LinuxDebPackager extends LinuxPackager { Set archPackages = new HashSet<>(); Set otherPackages = new HashSet<>(); - var debArch = LinuxPackageArch.getValue(LINUX_DEB); + var debArch = sysEnv.packageArch().value(); - Executor.of(dpkg.toString(), "-S", file.toString()) + Executor.of(sysEnv.dpkg().toString(), "-S", file.toString()) .saveOutput(true).executeExpectSuccess() .getOutput().forEach(line -> { Matcher matcher = PACKAGE_NAME_REGEX.matcher(line); diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironment.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironment.java index 5b5decb7a67..d5480361452 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironment.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -28,7 +28,7 @@ import static jdk.jpackage.internal.LinuxSystemEnvironment.mixin; import jdk.jpackage.internal.util.Result; -public interface LinuxDebSystemEnvironment extends LinuxSystemEnvironment, LinuxDebSystemEnvironmentMixin { +interface LinuxDebSystemEnvironment extends LinuxSystemEnvironment, LinuxDebSystemEnvironmentMixin { static Result create(Result base) { return mixin(LinuxDebSystemEnvironment.class, base, LinuxDebSystemEnvironmentMixin::create); diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironmentMixin.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironmentMixin.java index 8688327b353..2cf3e9e36e8 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironmentMixin.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironmentMixin.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -29,7 +29,7 @@ import java.util.Objects; import java.util.stream.Stream; import jdk.jpackage.internal.util.Result; -public interface LinuxDebSystemEnvironmentMixin { +interface LinuxDebSystemEnvironmentMixin { Path dpkg(); Path dpkgdeb(); Path fakeroot(); diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromOptions.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromOptions.java index 799c92ce2e1..0791c79c662 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromOptions.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromOptions.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -70,9 +70,9 @@ final class LinuxFromOptions { return LinuxApplication.create(appBuilder.create()); } - static LinuxRpmPackage createLinuxRpmPackage(Options options) { + static LinuxRpmPackage createLinuxRpmPackage(Options options, LinuxRpmSystemEnvironment sysEnv) { - final var superPkgBuilder = createLinuxPackageBuilder(options, LINUX_RPM); + final var superPkgBuilder = createLinuxPackageBuilder(options, sysEnv, LINUX_RPM); final var pkgBuilder = new LinuxRpmPackageBuilder(superPkgBuilder); @@ -81,9 +81,9 @@ final class LinuxFromOptions { return pkgBuilder.create(); } - static LinuxDebPackage createLinuxDebPackage(Options options) { + static LinuxDebPackage createLinuxDebPackage(Options options, LinuxDebSystemEnvironment sysEnv) { - final var superPkgBuilder = createLinuxPackageBuilder(options, LINUX_DEB); + final var superPkgBuilder = createLinuxPackageBuilder(options, sysEnv, LINUX_DEB); final var pkgBuilder = new LinuxDebPackageBuilder(superPkgBuilder); @@ -99,7 +99,7 @@ final class LinuxFromOptions { return pkg; } - private static LinuxPackageBuilder createLinuxPackageBuilder(Options options, StandardPackageType type) { + private static LinuxPackageBuilder createLinuxPackageBuilder(Options options, LinuxSystemEnvironment sysEnv, StandardPackageType type) { final var app = createLinuxApplication(options); @@ -107,6 +107,8 @@ final class LinuxFromOptions { final var pkgBuilder = new LinuxPackageBuilder(superPkgBuilder); + pkgBuilder.arch(sysEnv.packageArch()); + LINUX_PACKAGE_DEPENDENCIES.ifPresentIn(options, pkgBuilder::additionalDependencies); LINUX_APP_CATEGORY.ifPresentIn(options, pkgBuilder::category); LINUX_MENU_GROUP.ifPresentIn(options, pkgBuilder::menuGroupName); diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxLaunchersAsServices.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxLaunchersAsServices.java index b14404d67b1..40ff26bcfac 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxLaunchersAsServices.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxLaunchersAsServices.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2022, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -32,6 +32,7 @@ import java.util.List; import java.util.Map; import jdk.jpackage.internal.model.Launcher; import jdk.jpackage.internal.model.Package; +import jdk.jpackage.internal.util.Enquoter; /** * Helper to install launchers as services using "systemd". diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageArch.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageArch.java index 836d1fb2c37..b1df92ae312 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageArch.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageArch.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -25,18 +25,20 @@ package jdk.jpackage.internal; import java.io.IOException; -import static jdk.jpackage.internal.util.function.ThrowingSupplier.toSupplier; +import java.util.ArrayList; import jdk.jpackage.internal.model.StandardPackageType; +import jdk.jpackage.internal.util.CommandOutputControl; +import jdk.jpackage.internal.util.Result; -final class LinuxPackageArch { +record LinuxPackageArch(String value) { - static String getValue(StandardPackageType pkgType) { + static Result create(StandardPackageType pkgType) { switch (pkgType) { case LINUX_RPM -> { - return RpmPackageArch.VALUE; + return rpm().map(LinuxPackageArch::new); } case LINUX_DEB -> { - return DebPackageArch.VALUE; + return deb().map(LinuxPackageArch::new); } default -> { throw new IllegalArgumentException(); @@ -44,62 +46,51 @@ final class LinuxPackageArch { } } - private static class DebPackageArch { - - static final String VALUE = toSupplier(DebPackageArch::getValue).get(); - - private static String getValue() throws IOException { - return Executor.of("dpkg", "--print-architecture").saveOutput(true) - .executeExpectSuccess().getOutput().get(0); - } + private static Result deb() { + var exec = Executor.of("dpkg", "--print-architecture").saveOutput(true); + return Result.of(exec::executeExpectSuccess, IOException.class) + .flatMap(LinuxPackageArch::getStdoutFirstLine); } - private static class RpmPackageArch { - - /* - * Various ways to get rpm arch. Needed to address JDK-8233143. rpmbuild is mandatory for - * rpm packaging, try it first. rpm is optional and may not be available, use as the last - * resort. - */ - private static enum RpmArchReader { - Rpmbuild("rpmbuild", "--eval=%{_target_cpu}"), - Rpm("rpm", "--eval=%{_target_cpu}"); - - RpmArchReader(String... cmdline) { - this.cmdline = cmdline; + private static Result rpm() { + var errors = new ArrayList(); + for (var tool : RpmArchReader.values()) { + var result = tool.getRpmArch(); + if (result.hasValue()) { + return result; + } else { + errors.addAll(result.errors()); } - - String getRpmArch() throws IOException { - Executor exec = Executor.of(cmdline).saveOutput(true); - switch (this) { - case Rpm -> { - exec.executeExpectSuccess(); - } - case Rpmbuild -> { - if (exec.execute() != 0) { - return null; - } - } - default -> { - throw new UnsupportedOperationException(); - } - } - return exec.getOutput().get(0); - } - - private final String[] cmdline; } - static final String VALUE = toSupplier(RpmPackageArch::getValue).get(); + return Result.ofErrors(errors); + } - private static String getValue() throws IOException { - for (var rpmArchReader : RpmArchReader.values()) { - var rpmArchStr = rpmArchReader.getRpmArch(); - if (rpmArchStr != null) { - return rpmArchStr; - } - } - throw new RuntimeException("error.rpm-arch-not-detected"); + /* + * Various ways to get rpm arch. Needed to address JDK-8233143. rpmbuild is mandatory for + * rpm packaging, try it first. rpm is optional and may not be available, use as the last + * resort. + */ + private enum RpmArchReader { + RPMBUILD("rpmbuild", "--eval=%{_target_cpu}"), + RPM("rpm", "--eval=%{_target_cpu}"); + + RpmArchReader(String... cmdline) { + this.cmdline = cmdline; } + + Result getRpmArch() { + var exec = Executor.of(cmdline).saveOutput(true); + return Result.of(exec::executeExpectSuccess, IOException.class) + .flatMap(LinuxPackageArch::getStdoutFirstLine); + } + + private final String[] cmdline; + } + + private static Result getStdoutFirstLine(CommandOutputControl.Result result) { + return Result.of(() -> { + return result.stdout().stream().findFirst().orElseThrow(result::unexpected); + }, IOException.class); } } diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBuilder.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBuilder.java index bc7c301ace2..cd4d674432e 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBuilder.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -83,7 +83,7 @@ final class LinuxPackageBuilder { category(), Optional.ofNullable(additionalDependencies), release(), - pkg.asStandardPackageType().map(LinuxPackageArch::getValue).orElseThrow())); + arch.value())); } LinuxPackageBuilder literalName(String v) { @@ -119,6 +119,11 @@ final class LinuxPackageBuilder { return Optional.ofNullable(release); } + LinuxPackageBuilder arch(LinuxPackageArch v) { + arch = v; + return this; + } + private static LinuxApplicationLayout usrTreePackageLayout(Path prefix, String packageName) { final var lib = prefix.resolve(Path.of("lib", packageName)); return LinuxApplicationLayout.create( @@ -184,6 +189,7 @@ final class LinuxPackageBuilder { private String category; private String additionalDependencies; private String release; + private LinuxPackageArch arch; private final PackageBuilder pkgBuilder; diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironment.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironment.java index 58c10668227..e56551be325 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironment.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -28,7 +28,7 @@ import static jdk.jpackage.internal.LinuxSystemEnvironment.mixin; import jdk.jpackage.internal.util.Result; -public interface LinuxRpmSystemEnvironment extends LinuxSystemEnvironment, LinuxRpmSystemEnvironmentMixin { +interface LinuxRpmSystemEnvironment extends LinuxSystemEnvironment, LinuxRpmSystemEnvironmentMixin { static Result create(Result base) { return mixin(LinuxRpmSystemEnvironment.class, base, LinuxRpmSystemEnvironmentMixin::create); diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironmentMixin.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironmentMixin.java index b741495f5ed..4cbd3ce4a9c 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironmentMixin.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironmentMixin.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -32,7 +32,7 @@ import java.util.stream.Stream; import jdk.jpackage.internal.model.DottedVersion; import jdk.jpackage.internal.util.Result; -public interface LinuxRpmSystemEnvironmentMixin { +interface LinuxRpmSystemEnvironmentMixin { Path rpm(); Path rpmbuild(); diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxSystemEnvironment.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxSystemEnvironment.java index 1a70cc938b8..e347c58ae21 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxSystemEnvironment.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxSystemEnvironment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -27,7 +27,6 @@ package jdk.jpackage.internal; import java.io.IOException; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.function.Supplier; import jdk.jpackage.internal.model.PackageType; @@ -35,9 +34,10 @@ import jdk.jpackage.internal.model.StandardPackageType; import jdk.jpackage.internal.util.CompositeProxy; import jdk.jpackage.internal.util.Result; -public interface LinuxSystemEnvironment extends SystemEnvironment { +interface LinuxSystemEnvironment extends SystemEnvironment { boolean soLookupAvailable(); PackageType nativePackageType(); + LinuxPackageArch packageArch(); static Result create() { return detectNativePackageType().map(LinuxSystemEnvironment::create).orElseGet(() -> { @@ -45,7 +45,7 @@ public interface LinuxSystemEnvironment extends SystemEnvironment { }); } - static Optional detectNativePackageType() { + static Optional detectNativePackageType() { if (Internal.isDebian()) { return Optional.of(StandardPackageType.LINUX_DEB); } else if (Internal.isRpm()) { @@ -55,13 +55,14 @@ public interface LinuxSystemEnvironment extends SystemEnvironment { } } - static Result create(PackageType nativePackageType) { - return Result.ofValue(new Stub(LibProvidersLookup.supported(), - Objects.requireNonNull(nativePackageType))); + static Result create(StandardPackageType nativePackageType) { + return LinuxPackageArch.create(nativePackageType).map(arch -> { + return new Stub(LibProvidersLookup.supported(), nativePackageType, arch); + }); } static U createWithMixin(Class type, LinuxSystemEnvironment base, T mixin) { - return CompositeProxy.create(type, base, mixin); + return CompositeProxy.build().invokeTunnel(CompositeProxyTunnel.INSTANCE).create(type, base, mixin); } static Result mixin(Class type, @@ -79,7 +80,7 @@ public interface LinuxSystemEnvironment extends SystemEnvironment { } } - record Stub(boolean soLookupAvailable, PackageType nativePackageType) implements LinuxSystemEnvironment { + record Stub(boolean soLookupAvailable, PackageType nativePackageType, LinuxPackageArch packageArch) implements LinuxSystemEnvironment { } static final class Internal { diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/AppImageSigner.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/AppImageSigner.java index 71f87dd8705..81e04ad7ed1 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/AppImageSigner.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/AppImageSigner.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -48,6 +48,7 @@ import jdk.jpackage.internal.model.Launcher; import jdk.jpackage.internal.model.MacApplication; import jdk.jpackage.internal.model.RuntimeLayout; import jdk.jpackage.internal.util.PathUtils; +import jdk.jpackage.internal.util.Result; import jdk.jpackage.internal.util.function.ExceptionBox; @@ -188,11 +189,9 @@ final class AppImageSigner { } private static boolean isXcodeDevToolsInstalled() { - try { - return Executor.of("/usr/bin/xcrun", "--help").setQuiet(true).execute() == 0; - } catch (IOException ex) { - return false; - } + return Result.of( + Executor.of("/usr/bin/xcrun", "--help").setQuiet(true)::executeExpectSuccess, + IOException.class).hasValue(); } private static void unsign(Path path) throws IOException { diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/Codesign.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/Codesign.java index 920b75df398..a7cd17b06b9 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/Codesign.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/Codesign.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -34,7 +34,6 @@ import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Supplier; -import java.util.stream.Stream; public final class Codesign { @@ -94,14 +93,12 @@ public final class Codesign { public void applyTo(Path path) throws IOException, CodesignException { - var exec = Executor.of(Stream.concat( - cmdline.stream(), - Stream.of(path.toString())).toArray(String[]::new) - ).saveOutput(true); + var exec = Executor.of(cmdline).args(path.toString()).saveOutput(true); configureExecutor.ifPresent(configure -> configure.accept(exec)); - if (exec.execute() != 0) { - throw new CodesignException(exec.getOutput().toArray(String[]::new)); + var result = exec.execute(); + if (result.getExitCode() != 0) { + throw new CodesignException(result.getOutput().toArray(String[]::new)); } } diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacBundlingEnvironment.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacBundlingEnvironment.java index 3cecb2cd243..0531559e052 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacBundlingEnvironment.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacBundlingEnvironment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -36,7 +36,6 @@ import java.util.Optional; import jdk.jpackage.internal.cli.Options; import jdk.jpackage.internal.model.MacPackage; import jdk.jpackage.internal.model.Package; -import jdk.jpackage.internal.util.Result; public class MacBundlingEnvironment extends DefaultBundlingEnvironment { @@ -45,7 +44,7 @@ public class MacBundlingEnvironment extends DefaultBundlingEnvironment { .defaultOperation(CREATE_MAC_DMG) .bundler(SIGN_MAC_APP_IMAGE, MacBundlingEnvironment::signAppImage) .bundler(CREATE_MAC_APP_IMAGE, MacBundlingEnvironment::createAppImage) - .bundler(CREATE_MAC_DMG, LazyLoad::dmgSysEnv, MacBundlingEnvironment::createDmdPackage) + .bundler(CREATE_MAC_DMG, MacDmgSystemEnvironment::create, MacBundlingEnvironment::createDmdPackage) .bundler(CREATE_MAC_PKG, MacBundlingEnvironment::createPkgPackage)); } @@ -98,13 +97,4 @@ public class MacBundlingEnvironment extends DefaultBundlingEnvironment { .predefinedAppImageLayout(APPLICATION_LAYOUT) .predefinedRuntimeImageLayout(MacPackage::guessRuntimeLayout); } - - private static final class LazyLoad { - - static Result dmgSysEnv() { - return DMG_SYS_ENV; - } - - private static final Result DMG_SYS_ENV = MacDmgSystemEnvironment.create(); - } } diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacCertificateUtils.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacCertificateUtils.java index fe593e347fc..24a236ae15d 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacCertificateUtils.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacCertificateUtils.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -53,7 +53,7 @@ public final class MacCertificateUtils { keychain.map(Keychain::asCliArg).ifPresent(args::add); return toSupplier(() -> { - final var output = Executor.of(args.toArray(String[]::new)) + final var output = Executor.of(args) .setQuiet(true).saveOutput(true).executeExpectSuccess() .getOutput(); diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgPackager.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgPackager.java index 4ccc459109f..20a687487ef 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgPackager.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgPackager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -33,11 +33,15 @@ import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.text.MessageFormat; +import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.function.Function; import jdk.jpackage.internal.PackagingPipeline.PackageTaskID; import jdk.jpackage.internal.PackagingPipeline.TaskID; import jdk.jpackage.internal.model.MacDmgPackage; @@ -105,6 +109,10 @@ record MacDmgPackager(BuildEnv env, MacDmgPackage pkg, Path outputDir, return env.configDir().resolve(pkg.app().name() + "-license.plist"); } + private Path finalDmg() { + return outputDir.resolve(pkg.packageFileNameWithSuffix()); + } + Path protoDmg() { return dmgWorkdir().resolve("proto.dmg"); } @@ -128,6 +136,10 @@ record MacDmgPackager(BuildEnv env, MacDmgPackage pkg, Path outputDir, } } + private Executor hdiutil(String... args) { + return Executor.of(sysEnv.hdiutil().toString()).args(args).storeOutputInFiles(); + } + private void prepareDMGSetupScript() throws IOException { Path dmgSetup = volumeScript(); Log.verbose(MessageFormat.format( @@ -211,13 +223,17 @@ record MacDmgPackager(BuildEnv env, MacDmgPackage pkg, Path outputDir, } } + private String hdiUtilVerbosityFlag() { + return env.verbose() ? "-verbose" : "-quiet"; + } + private void buildDMG() throws IOException { boolean copyAppImage = false; - Path protoDMG = protoDmg(); - Path finalDMG = outputDir.resolve(pkg.packageFileNameWithSuffix()); + final Path protoDMG = protoDmg(); + final Path finalDMG = finalDmg(); - Path srcFolder = env.appImageDir(); + final Path srcFolder = env.appImageDir(); Log.verbose(MessageFormat.format(I18N.getString( "message.creating-dmg-file"), finalDMG.toAbsolutePath())); @@ -233,21 +249,17 @@ record MacDmgPackager(BuildEnv env, MacDmgPackage pkg, Path outputDir, Files.createDirectories(protoDMG.getParent()); Files.createDirectories(finalDMG.getParent()); - String hdiUtilVerbosityFlag = env.verbose() ? - "-verbose" : "-quiet"; + final String hdiUtilVerbosityFlag = hdiUtilVerbosityFlag(); // create temp image - ProcessBuilder pb = new ProcessBuilder( - sysEnv.hdiutil().toString(), - "create", - hdiUtilVerbosityFlag, - "-srcfolder", normalizedAbsolutePathString(srcFolder), - "-volname", volumeName(), - "-ov", normalizedAbsolutePathString(protoDMG), - "-fs", "HFS+", - "-format", "UDRW"); try { - IOUtils.exec(pb, false, null, true, Executor.INFINITE_TIMEOUT); + hdiutil("create", + hdiUtilVerbosityFlag, + "-srcfolder", normalizedAbsolutePathString(srcFolder), + "-volname", volumeName(), + "-ov", normalizedAbsolutePathString(protoDMG), + "-fs", "HFS+", + "-format", "UDRW").executeExpectSuccess(); } catch (IOException ex) { Log.verbose(ex); // Log exception @@ -260,31 +272,26 @@ record MacDmgPackager(BuildEnv env, MacDmgPackage pkg, Path outputDir, // not be bigger, but it will able to hold additional 50 megabytes of data. // We need extra room for icons and background image. When we providing // actual files to hdiutil, it will create DMG with ~50 megabytes extra room. - pb = new ProcessBuilder( - sysEnv.hdiutil().toString(), - "create", - hdiUtilVerbosityFlag, - "-size", String.valueOf(size), - "-volname", volumeName(), - "-ov", normalizedAbsolutePathString(protoDMG), - "-fs", "HFS+"); - new RetryExecutor() - .setMaxAttemptsCount(10) - .setAttemptTimeoutMillis(3000) - .setWriteOutputToFile(true) - .execute(pb); + hdiutil( + "create", + hdiUtilVerbosityFlag, + "-size", String.valueOf(size), + "-volname", volumeName(), + "-ov", normalizedAbsolutePathString(protoDMG), + "-fs", "HFS+" + ).retry() + .setMaxAttemptsCount(10) + .setAttemptTimeout(3, TimeUnit.SECONDS) + .execute(); } + final Path mountedVolume = volumePath(); + // mount temp image - pb = new ProcessBuilder( - sysEnv.hdiutil().toString(), - "attach", + hdiutil("attach", normalizedAbsolutePathString(protoDMG), hdiUtilVerbosityFlag, - "-mountroot", protoDMG.getParent().toString()); - IOUtils.exec(pb, false, null, true, Executor.INFINITE_TIMEOUT); - - final Path mountedVolume = volumePath(); + "-mountroot", mountedVolume.getParent().toString()).executeExpectSuccess(); // Copy app image, since we did not create DMG with it, but instead we created // empty one. @@ -302,9 +309,13 @@ record MacDmgPackager(BuildEnv env, MacDmgPackage pkg, Path outputDir, // to install-dir in DMG as critical error, since it can fail in // headless environment. try { - pb = new ProcessBuilder(sysEnv.osascript().toString(), - normalizedAbsolutePathString(volumeScript())); - IOUtils.exec(pb, 180); // Wait 3 minutes. See JDK-8248248. + Executor.of( + sysEnv.osascript().toString(), + normalizedAbsolutePathString(volumeScript()) + ) + // Wait 3 minutes. See JDK-8248248. + .timeout(3, TimeUnit.MINUTES) + .executeExpectSuccess(); } catch (IOException ex) { Log.verbose(ex); } @@ -325,18 +336,18 @@ record MacDmgPackager(BuildEnv env, MacDmgPackage pkg, Path outputDir, // but it seems Finder excepts these bytes to be // "icnC" for the volume icon // (might not work on Mac 10.13 with old XCode) - pb = new ProcessBuilder( + Executor.of( sysEnv.setFileUtility().orElseThrow().toString(), "-c", "icnC", - normalizedAbsolutePathString(volumeIconFile)); - IOUtils.exec(pb); + normalizedAbsolutePathString(volumeIconFile) + ).executeExpectSuccess(); volumeIconFile.toFile().setReadOnly(); - pb = new ProcessBuilder( + Executor.of( sysEnv.setFileUtility().orElseThrow().toString(), "-a", "C", - normalizedAbsolutePathString(mountedVolume)); - IOUtils.exec(pb); + normalizedAbsolutePathString(mountedVolume) + ).executeExpectSuccess(); } catch (IOException ex) { Log.error(ex.getMessage()); Log.verbose("Cannot enable custom icon using SetFile utility"); @@ -347,85 +358,23 @@ record MacDmgPackager(BuildEnv env, MacDmgPackage pkg, Path outputDir, } finally { // Detach the temporary image - pb = new ProcessBuilder( - sysEnv.hdiutil().toString(), - "detach", - hdiUtilVerbosityFlag, - normalizedAbsolutePathString(mountedVolume)); - // "hdiutil detach" might not work right away due to resource busy error, so - // repeat detach several times. - RetryExecutor retryExecutor = new RetryExecutor(); - // Image can get detach even if we got resource busy error, so stop - // trying to detach it if it is no longer attached. - retryExecutor.setExecutorInitializer(exec -> { - if (!Files.exists(mountedVolume)) { - retryExecutor.abort(); - } - }); - try { - // 10 times with 6 second delays. - retryExecutor.setMaxAttemptsCount(10).setAttemptTimeoutMillis(6000) - .execute(pb); - } catch (IOException ex) { - if (!retryExecutor.isAborted()) { - // Now force to detach if it still attached - if (Files.exists(mountedVolume)) { - pb = new ProcessBuilder( - sysEnv.hdiutil().toString(), - "detach", - "-force", - hdiUtilVerbosityFlag, - normalizedAbsolutePathString(mountedVolume)); - IOUtils.exec(pb, false, null, true, Executor.INFINITE_TIMEOUT); - } - } - } + detachVolume(); } // Compress it to a new image - pb = new ProcessBuilder( - sysEnv.hdiutil().toString(), - "convert", - normalizedAbsolutePathString(protoDMG), - hdiUtilVerbosityFlag, - "-format", "UDZO", - "-o", normalizedAbsolutePathString(finalDMG)); - try { - new RetryExecutor() - .setMaxAttemptsCount(10) - .setAttemptTimeoutMillis(3000) - .execute(pb); - } catch (Exception ex) { - // Convert might failed if something holds file. Try to convert copy. - Path protoCopyDMG = protoCopyDmg(); - Files.copy(protoDMG, protoCopyDMG); - try { - pb = new ProcessBuilder( - sysEnv.hdiutil().toString(), - "convert", - normalizedAbsolutePathString(protoCopyDMG), - hdiUtilVerbosityFlag, - "-format", "UDZO", - "-o", normalizedAbsolutePathString(finalDMG)); - IOUtils.exec(pb, false, null, true, Executor.INFINITE_TIMEOUT); - } finally { - Files.deleteIfExists(protoCopyDMG); - } - } + convertProtoDmg(); //add license if needed if (pkg.licenseFile().isPresent()) { - pb = new ProcessBuilder( - sysEnv.hdiutil().toString(), + hdiutil( "udifrez", normalizedAbsolutePathString(finalDMG), "-xml", normalizedAbsolutePathString(licenseFile()) - ); - new RetryExecutor() - .setMaxAttemptsCount(10) - .setAttemptTimeoutMillis(3000) - .execute(pb); + ).retry() + .setMaxAttemptsCount(10) + .setAttemptTimeout(3, TimeUnit.SECONDS) + .execute(); } try { @@ -441,6 +390,69 @@ record MacDmgPackager(BuildEnv env, MacDmgPackage pkg, Path outputDir, } + private void detachVolume() throws IOException { + var mountedVolume = volumePath(); + + // "hdiutil detach" might not work right away due to resource busy error, so + // repeat detach several times. + Globals.instance().objectFactory().retryExecutor(IOException.class).setExecutable(context -> { + + List cmdline = new ArrayList<>(); + cmdline.add("detach"); + + if (context.isLastAttempt()) { + // The last attempt, force detach. + cmdline.add("-force"); + } + + cmdline.addAll(List.of( + hdiUtilVerbosityFlag(), + normalizedAbsolutePathString(mountedVolume) + )); + + // The image can get detached even if we get a resource busy error, + // so execute the detach command without checking the exit code. + var result = hdiutil(cmdline.toArray(String[]::new)).execute(); + + if (result.getExitCode() == 0 || !Files.exists(mountedVolume)) { + // Detached successfully! + return null; + } else { + throw result.unexpected(); + } + }).setMaxAttemptsCount(10).setAttemptTimeout(6, TimeUnit.SECONDS).execute(); + } + + private void convertProtoDmg() throws IOException { + + Function convert = srcDmg -> { + return hdiutil( + "convert", + normalizedAbsolutePathString(srcDmg), + hdiUtilVerbosityFlag(), + "-format", "UDZO", + "-o", normalizedAbsolutePathString(finalDmg())); + }; + + // Convert it to a new image. + try { + convert.apply(protoDmg()).retry() + .setMaxAttemptsCount(10) + .setAttemptTimeout(3, TimeUnit.SECONDS) + .execute(); + } catch (IOException ex) { + Log.verbose(ex); + // Something holds the file, try to convert a copy. + Path copyDmg = protoCopyDmg(); + Files.copy(protoDmg(), copyDmg); + try { + convert.apply(copyDmg).executeExpectSuccess(); + } finally { + Files.deleteIfExists(copyDmg); + } + } + } + // Background image name in resources private static final String DEFAULT_BACKGROUND_IMAGE = "background_dmg.tiff"; private static final String DEFAULT_DMG_SETUP_SCRIPT = "DMGsetup.scpt"; diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgSystemEnvironment.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgSystemEnvironment.java index 54eb0c6f4fe..12d105b99b5 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgSystemEnvironment.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgSystemEnvironment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -25,10 +25,11 @@ package jdk.jpackage.internal; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; import java.util.stream.Stream; import jdk.jpackage.internal.util.Result; @@ -54,41 +55,31 @@ record MacDmgSystemEnvironment(Path hdiutil, Path osascript, Optional setF // Location of SetFile utility may be different depending on MacOS version // We look for several known places and if none of them work will // try to find it - private static Optional findSetFileUtility() { - String typicalPaths[] = {"/Developer/Tools/SetFile", - "/usr/bin/SetFile", "/Developer/usr/bin/SetFile"}; + static Optional findSetFileUtility() { + return SETFILE_KNOWN_PATHS.stream().filter(setFilePath -> { + // Validate SetFile, if Xcode is not installed it will run, but exit with error code + return Result.of( + Executor.of(setFilePath.toString(), "-h").setQuiet(true)::executeExpectSuccess, + IOException.class).hasValue(); + }).findFirst().or(() -> { + // generic find attempt + final var executor = Executor.of("/usr/bin/xcrun", "-find", "SetFile").setQuiet(true).saveFirstLineOfOutput(); - final var setFilePath = Stream.of(typicalPaths).map(Path::of).filter(Files::isExecutable).findFirst(); - if (setFilePath.isPresent()) { - // Validate SetFile, if Xcode is not installed it will run, but exit with error - // code - try { - if (Executor.of(setFilePath.orElseThrow().toString(), "-h").setQuiet(true).execute() == 0) { - return setFilePath; - } - } catch (Exception ignored) { - // No need for generic find attempt. We found it, but it does not work. - // Probably due to missing xcode. - return Optional.empty(); - } - } - - // generic find attempt - try { - final var executor = Executor.of("/usr/bin/xcrun", "-find", "SetFile"); - final var code = executor.setQuiet(true).saveOutput(true).execute(); - if (code == 0 && !executor.getOutput().isEmpty()) { - final var firstLine = executor.getOutput().getFirst(); - Path f = Path.of(firstLine); - if (new ToolValidator(f).checkExistsOnly().validate() == null) { - return Optional.of(f.toAbsolutePath()); - } - } - } catch (IOException ignored) {} - - return Optional.empty(); + return Result.of(executor::executeExpectSuccess, IOException.class).flatMap(execResult -> { + return Result.of(() -> { + return execResult.stdout().stream().findFirst().map(Path::of).orElseThrow(execResult::unexpected); + }, Exception.class); + }).value().filter(v -> { + return new ToolValidator(v).checkExistsOnly().validate() == null; + }).map(Path::toAbsolutePath); + }); } + static final List SETFILE_KNOWN_PATHS = Stream.of( + "/Developer/Tools/SetFile", + "/usr/bin/SetFile", + "/Developer/usr/bin/SetFile").map(Path::of).collect(Collectors.toUnmodifiableList()); + private static final Path HDIUTIL = Path.of("/usr/bin/hdiutil"); private static final Path OSASCRIPT = Path.of("/usr/bin/osascript"); } diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgPackager.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgPackager.java index 127b3232661..126248e2330 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgPackager.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgPackager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -32,7 +32,6 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.net.URI; import java.net.URISyntaxException; -import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.text.MessageFormat; @@ -57,6 +56,7 @@ import jdk.jpackage.internal.PackagingPipeline.PackageTaskID; import jdk.jpackage.internal.PackagingPipeline.TaskID; import jdk.jpackage.internal.model.MacPkgPackage; import jdk.jpackage.internal.resources.ResourceLocator; +import jdk.jpackage.internal.util.Enquoter; import jdk.jpackage.internal.util.XmlUtils; import org.xml.sax.SAXException; @@ -108,7 +108,7 @@ record MacPkgPackager(BuildEnv env, MacPkgPackage pkg, Optional servic cmdline.addAll(allPkgbuildArgs()); try { Files.createDirectories(path.getParent()); - IOUtils.exec(new ProcessBuilder(cmdline), false, null, true, Executor.INFINITE_TIMEOUT); + Executor.of(cmdline).executeExpectSuccess(); } catch (IOException ex) { throw new UncheckedIOException(ex); } @@ -487,15 +487,13 @@ record MacPkgPackager(BuildEnv env, MacPkgPackage pkg, Optional servic Files.createDirectories(cpl.getParent()); - final var pb = new ProcessBuilder("/usr/bin/pkgbuild", + Executor.of("/usr/bin/pkgbuild", "--root", normalizedAbsolutePathString(env.appImageDir()), "--install-location", normalizedAbsolutePathString(installLocation()), "--analyze", - normalizedAbsolutePathString(cpl)); - - IOUtils.exec(pb, false, null, true, Executor.INFINITE_TIMEOUT); + normalizedAbsolutePathString(cpl)).executeExpectSuccess(); patchCPLFile(cpl); } @@ -544,8 +542,7 @@ record MacPkgPackager(BuildEnv env, MacPkgPackage pkg, Optional servic } commandLine.add(normalizedAbsolutePathString(finalPkg)); - final var pb = new ProcessBuilder(commandLine); - IOUtils.exec(pb, false, null, true, Executor.INFINITE_TIMEOUT); + Executor.of(commandLine).executeExpectSuccess(); } private static Optional createServices(BuildEnv env, MacPkgPackage pkg) { diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/TempKeychain.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/TempKeychain.java index b38faecd96f..2f616aafba1 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/TempKeychain.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/TempKeychain.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -27,15 +27,17 @@ package jdk.jpackage.internal; import java.io.Closeable; import java.io.IOException; +import java.io.UncheckedIOException; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; import jdk.internal.util.OSVersion; -import jdk.jpackage.internal.util.function.ThrowingConsumer; final class TempKeychain implements Closeable { - static void withKeychains(ThrowingConsumer, ? extends Exception> keychainConsumer, List keychains) throws Exception { + static void withKeychains(Consumer> keychainConsumer, List keychains) { + keychains.forEach(Objects::requireNonNull); if (keychains.isEmpty() || OSVersion.current().compareTo(new OSVersion(10, 12)) < 0) { keychainConsumer.accept(keychains); @@ -43,11 +45,14 @@ final class TempKeychain implements Closeable { // we need this for OS X 10.12+ try (var tempKeychain = new TempKeychain(keychains)) { keychainConsumer.accept(tempKeychain.keychains); + } catch (IOException ex) { + throw new UncheckedIOException(ex); } } } - static void withKeychain(ThrowingConsumer keychainConsumer, Keychain keychain) throws Exception { + static void withKeychain(Consumer keychainConsumer, Keychain keychain) { + Objects.requireNonNull(keychainConsumer); withKeychains(keychains -> { keychainConsumer.accept(keychains.getFirst()); @@ -78,7 +83,7 @@ final class TempKeychain implements Closeable { args.addAll(missingKeychains.stream().map(Keychain::asCliArg).toList()); - Executor.of(args.toArray(String[]::new)).executeExpectSuccess(); + Executor.of(args).executeExpectSuccess(); } } @@ -89,7 +94,7 @@ final class TempKeychain implements Closeable { @Override public void close() throws IOException { if (!restoreKeychainsCmd.isEmpty()) { - Executor.of(restoreKeychainsCmd.toArray(String[]::new)).executeExpectSuccess(); + Executor.of(restoreKeychainsCmd).executeExpectSuccess(); } } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/DefaultBundlingEnvironment.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/DefaultBundlingEnvironment.java index 05e080f240a..e4473b1e5ce 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/DefaultBundlingEnvironment.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/DefaultBundlingEnvironment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -65,10 +65,10 @@ class DefaultBundlingEnvironment implements CliBundlingEnvironment { Map>>> bundlers) { this.bundlers = bundlers.entrySet().stream().collect(toMap(Map.Entry::getKey, e -> { - return new CachingSupplier<>(e.getValue()); + return runOnce(e.getValue()); })); - this.defaultOperationSupplier = Objects.requireNonNull(defaultOperationSupplier).map(CachingSupplier::new); + this.defaultOperationSupplier = Objects.requireNonNull(defaultOperationSupplier).map(DefaultBundlingEnvironment::runOnce); } @@ -98,6 +98,11 @@ class DefaultBundlingEnvironment implements CliBundlingEnvironment { return bundler(op, () -> Result.ofValue(bundler)); } + Builder mutate(Consumer mutator) { + mutator.accept(this); + return this; + } + private Supplier> defaultOperationSupplier; private final Map>>> bundlers = new HashMap<>(); } @@ -107,6 +112,10 @@ class DefaultBundlingEnvironment implements CliBundlingEnvironment { return new Builder(); } + static Supplier runOnce(Supplier supplier) { + return new CachingSupplier<>(supplier); + } + static Supplier>> createBundlerSupplier( Supplier> sysEnvResultSupplier, BiConsumer bundler) { Objects.requireNonNull(sysEnvResultSupplier); @@ -279,5 +288,5 @@ class DefaultBundlingEnvironment implements CliBundlingEnvironment { private final Map>>> bundlers; - private final Optional>> defaultOperationSupplier; + private final Optional>> defaultOperationSupplier; } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Executor.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Executor.java index dd1cc4a24b4..ca7a630b6d1 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Executor.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Executor.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -25,53 +25,153 @@ package jdk.jpackage.internal; import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.file.Files; -import java.nio.file.Path; +import java.io.PrintStream; +import java.io.StringReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; -import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.spi.ToolProvider; import java.util.stream.Stream; +import jdk.jpackage.internal.util.CommandLineFormat; +import jdk.jpackage.internal.util.CommandOutputControl; +import jdk.jpackage.internal.util.CommandOutputControl.ProcessAttributes; +import jdk.jpackage.internal.util.CommandOutputControl.Result; +import jdk.jpackage.internal.util.RetryExecutor; +import jdk.jpackage.internal.util.function.ExceptionBox; -public final class Executor { +final class Executor { - Executor() { + static Executor of(String... cmdline) { + return of(List.of(cmdline)); } - Executor setOutputConsumer(Consumer> v) { - outputConsumer = v; - return this; + static Executor of(List cmdline) { + return of(new ProcessBuilder(cmdline)); + } + + static Executor of(ProcessBuilder pb) { + return Globals.instance().objectFactory().executor().processBuilder(pb); + } + + public Executor() { + commandOutputControl = new CommandOutputControl(); + args = new ArrayList<>(); + } + + private Executor(Executor other) { + commandOutputControl = other.commandOutputControl.copy(); + quietCommand = other.quietCommand; + args = new ArrayList<>(other.args); + processBuilder = other.processBuilder; + toolProvider = other.toolProvider; + timeout = other.timeout; + mapper = other.mapper; } Executor saveOutput(boolean v) { - saveOutput = v; + commandOutputControl.saveOutput(v); return this; } - Executor setWriteOutputToFile(boolean v) { - writeOutputToFile = v; + Executor saveOutput() { + return saveOutput(true); + } + + Executor saveFirstLineOfOutput() { + commandOutputControl.saveFirstLineOfOutput(); return this; } - Executor setTimeout(long v) { + Executor charset(Charset v) { + commandOutputControl.charset(v); + return this; + } + + Executor storeOutputInFiles(boolean v) { + commandOutputControl.storeOutputInFiles(v); + return this; + } + + Executor storeOutputInFiles() { + return storeOutputInFiles(true); + } + + Executor binaryOutput(boolean v) { + commandOutputControl.binaryOutput(v); + return this; + } + + Executor binaryOutput() { + return binaryOutput(true); + } + + Executor discardStdout(boolean v) { + commandOutputControl.discardStdout(v); + return this; + } + + Executor discardStdout() { + return discardStdout(true); + } + + Executor discardStderr(boolean v) { + commandOutputControl.discardStderr(v); + return this; + } + + Executor discardStderr() { + return discardStderr(true); + } + + Executor timeout(long v, TimeUnit unit) { + return timeout(Duration.of(v, unit.toChronoUnit())); + } + + Executor timeout(Duration v) { timeout = v; - if (timeout != INFINITE_TIMEOUT) { - // Redirect output to file if timeout is requested, otherwise we will - // reading until process ends and timeout will never be reached. - setWriteOutputToFile(true); - } return this; } - Executor setProcessBuilder(ProcessBuilder v) { - pb = v; + Executor toolProvider(ToolProvider v) { + toolProvider = Objects.requireNonNull(v); + processBuilder = null; return this; } - Executor setCommandLine(String... cmdline) { - return setProcessBuilder(new ProcessBuilder(cmdline)); + Optional toolProvider() { + return Optional.ofNullable(toolProvider); + } + + Executor processBuilder(ProcessBuilder v) { + processBuilder = Objects.requireNonNull(v); + toolProvider = null; + return this; + } + + Optional processBuilder() { + return Optional.ofNullable(processBuilder); + } + + Executor args(List v) { + args.addAll(v); + return this; + } + + Executor args(String... args) { + return args(List.of(args)); + } + + List args() { + return args; } Executor setQuiet(boolean v) { @@ -79,159 +179,207 @@ public final class Executor { return this; } - List getOutput() { - return output; - } - - Executor executeExpectSuccess() throws IOException { - int ret = execute(); - if (0 != ret) { - throw new IOException( - String.format("Command %s exited with %d code", - createLogMessage(pb, false), ret)); - } + Executor mapper(UnaryOperator v) { + mapper = v; return this; } - int execute() throws IOException { - output = null; + Optional> mapper() { + return Optional.ofNullable(mapper); + } - boolean needProcessOutput = outputConsumer != null || Log.isVerbose() || saveOutput; - Path outputFile = null; - if (needProcessOutput) { - pb.redirectErrorStream(true); - if (writeOutputToFile) { - outputFile = Files.createTempFile("jpackageOutputTempFile", ".tmp"); - pb.redirectOutput(outputFile.toFile()); + Executor copy() { + return new Executor(this); + } + + Result execute() throws IOException { + if (mapper != null) { + var mappedExecutor = Objects.requireNonNull(mapper.apply(this)); + if (mappedExecutor != this) { + return mappedExecutor.execute(); } + } + + var coc = commandOutputControl.copy(); + + final CommandOutputControl.Executable exec; + if (processBuilder != null) { + exec = coc.createExecutable(copyProcessBuilder()); + } else if (toolProvider != null) { + exec = coc.createExecutable(toolProvider, args.toArray(String[]::new)); } else { - // We are not going to read process output, so need to notify - // ProcessBuilder about this. Otherwise some processes might just - // hang up (`ldconfig -p`). - pb.redirectError(ProcessBuilder.Redirect.DISCARD); - pb.redirectOutput(ProcessBuilder.Redirect.DISCARD); + throw new IllegalStateException("No target to execute"); } - if (!quietCommand) { - Log.verbose(String.format("Running %s", createLogMessage(pb, true))); + PrintableOutputBuilder printableOutputBuilder; + if (dumpOutput()) { + printableOutputBuilder = new PrintableOutputBuilder(coc); + } else { + printableOutputBuilder = null; } - Process p = pb.start(); - - int code = 0; - if (writeOutputToFile) { - try { - code = waitForProcess(p); - } catch (InterruptedException ex) { - Log.verbose(ex); - throw new RuntimeException(ex); - } - } - - if (needProcessOutput) { - final List savedOutput; - Supplier> outputStream; - - if (writeOutputToFile) { - output = savedOutput = Files.readAllLines(outputFile); - Files.delete(outputFile); - outputStream = () -> { - if (savedOutput != null) { - return savedOutput.stream(); - } - return null; - }; - if (outputConsumer != null) { - outputConsumer.accept(outputStream.get()); - } - } else { - try (var br = new BufferedReader(new InputStreamReader( - p.getInputStream()))) { - - if ((outputConsumer != null || Log.isVerbose()) - || saveOutput) { - savedOutput = br.lines().toList(); - } else { - savedOutput = null; - } - output = savedOutput; - - outputStream = () -> { - if (savedOutput != null) { - return savedOutput.stream(); - } - return br.lines(); - }; - if (outputConsumer != null) { - outputConsumer.accept(outputStream.get()); - } - - if (savedOutput == null) { - // For some processes on Linux if the output stream - // of the process is opened but not consumed, the process - // would exit with code 141. - // It turned out that reading just a single line of process - // output fixes the problem, but let's process - // all of the output, just in case. - br.lines().forEach(x -> {}); - } - } - } + if (dumpOutput()) { + Log.verbose(String.format("Running %s", CommandLineFormat.DEFAULT.apply(List.of(commandLine().getFirst())))); } + Result result; try { - if (!writeOutputToFile) { - code = p.waitFor(); - } - if (!quietCommand) { - Log.verbose(pb.command(), getOutput(), code, IOUtils.getPID(p)); - } - return code; - } catch (InterruptedException ex) { - Log.verbose(ex); - throw new RuntimeException(ex); - } - } - - private int waitForProcess(Process p) throws InterruptedException { - if (timeout == INFINITE_TIMEOUT) { - return p.waitFor(); - } else { - if (p.waitFor(timeout, TimeUnit.SECONDS)) { - return p.exitValue(); + if (timeout == null) { + result = exec.execute(); } else { - Log.verbose(String.format("Command %s timeout after %d seconds", - createLogMessage(pb, false), timeout)); - p.destroy(); - return -1; + result = exec.execute(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + } catch (InterruptedException ex) { + throw ExceptionBox.toUnchecked(ex); + } + + if (dumpOutput()) { + log(result, printableOutputBuilder.create()); + } + + return result; + } + + Result executeExpectSuccess() throws IOException { + return execute().expectExitCode(0); + } + + Result executeExpect(int mainExitCode, int... otherExitCodes) throws IOException { + return execute().expectExitCode(mainExitCode, otherExitCodes); + } + + RetryExecutor retry() { + return Globals.instance().objectFactory().retryExecutor(IOException.class) + .setExecutable(this::executeExpectSuccess); + } + + RetryExecutor retryOnKnownErrorMessage(String msg) { + Objects.requireNonNull(msg); + return saveOutput().retry().setExecutable(() -> { + // Execute it without exit code check. + var result = execute(); + if (result.stderr().stream().anyMatch(msg::equals)) { + throw result.unexpected(); + } + return result; + }); + } + + List commandLine() { + if (processBuilder != null) { + return Stream.of(processBuilder.command(), args).flatMap(Collection::stream).toList(); + } else if (toolProvider != null) { + return Stream.concat(Stream.of(toolProvider.name()), args.stream()).toList(); + } else { + throw new IllegalStateException("No target to execute"); + } + } + + private ProcessBuilder copyProcessBuilder() { + if (processBuilder == null) { + throw new IllegalStateException(); + } + + var copy = new ProcessBuilder(commandLine()); + copy.directory(processBuilder.directory()); + var env = copy.environment(); + env.clear(); + env.putAll(processBuilder.environment()); + + return copy; + } + + private boolean dumpOutput() { + return Log.isVerbose() && !quietCommand; + } + + private static void log(Result result, String printableOutput) throws IOException { + Objects.requireNonNull(result); + Objects.requireNonNull(printableOutput); + + Optional pid; + if (result.execAttrs() instanceof ProcessAttributes attrs) { + pid = attrs.pid(); + } else { + pid = Optional.empty(); + } + + var sb = new StringBuilder(); + sb.append("Command"); + pid.ifPresent(p -> { + sb.append(" [PID: ").append(p).append("]"); + }); + sb.append(":\n ").append(result.execAttrs()); + Log.verbose(sb.toString()); + + if (!printableOutput.isEmpty()) { + sb.delete(0, sb.length()); + sb.append("Output:"); + try (var lines = new BufferedReader(new StringReader(printableOutput)).lines()) { + lines.forEach(line -> { + sb.append("\n ").append(line); + }); + } + Log.verbose(sb.toString()); + } + + result.exitCode().ifPresentOrElse(exitCode -> { + Log.verbose("Returned: " + exitCode + "\n"); + }, () -> { + Log.verbose("Aborted: timed-out" + "\n"); + }); + } + + private static final class PrintableOutputBuilder { + + PrintableOutputBuilder(CommandOutputControl coc) { + coc.dumpOutput(true); + charset = coc.charset(); + if (coc.isBinaryOutput()) { + // Assume binary output goes into stdout and text error messages go into stderr, so keep them separated. + sinks = new ByteArrayOutputStream[2]; + sinks[0] = new ByteArrayOutputStream(); + sinks[1] = new ByteArrayOutputStream(); + coc.dumpStdout(new PrintStream(sinks[0], false, charset)) + .dumpStderr(new PrintStream(sinks[1], false, charset)); + } else { + sinks = new ByteArrayOutputStream[1]; + sinks[0] = new ByteArrayOutputStream(); + var ps = new PrintStream(sinks[0], false, charset); + // Redirect stderr in stdout. + coc.dumpStdout(ps).dumpStderr(ps); } } - } - static Executor of(String... cmdline) { - return new Executor().setCommandLine(cmdline); - } - - static Executor of(ProcessBuilder pb) { - return new Executor().setProcessBuilder(pb); - } - - private static String createLogMessage(ProcessBuilder pb, boolean quiet) { - StringBuilder sb = new StringBuilder(); - sb.append((quiet) ? pb.command().get(0) : pb.command()); - if (pb.directory() != null) { - sb.append(String.format(" in %s", pb.directory().getAbsolutePath())); + String create() { + if (isBinaryOutput()) { + // In case of binary output: + // - Convert binary stdout to text using ISO-8859-1 encoding and + // replace non-printable characters with the question mark symbol (?). + // - Convert binary stderr to text using designated encoding (assume stderr is always a character stream). + // - Merge text stdout and stderr into a single string; + // stderr first, stdout follows, with the aim to present user error messages first. + var sb = new StringBuilder(); + var stdout = sinks[0].toString(StandardCharsets.ISO_8859_1).replaceAll("[^\\p{Print}\\p{Space}]", "?"); + return sb.append(sinks[1].toString(charset)).append(stdout).toString(); + } else { + return sinks[0].toString(charset); + } } - return sb.toString(); + + private boolean isBinaryOutput() { + return sinks.length == 2; + } + + private final ByteArrayOutputStream sinks[]; + private final Charset charset; } - public static final int INFINITE_TIMEOUT = -1; - - private ProcessBuilder pb; - private boolean saveOutput; - private boolean writeOutputToFile; + private final CommandOutputControl commandOutputControl; private boolean quietCommand; - private long timeout = INFINITE_TIMEOUT; - private List output; - private Consumer> outputConsumer; + private final List args; + private ProcessBuilder processBuilder; + private ToolProvider toolProvider; + private Duration timeout; + private UnaryOperator mapper; } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ExecutorFactory.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ExecutorFactory.java new file mode 100644 index 00000000000..ce703358b82 --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ExecutorFactory.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal; + +@FunctionalInterface +interface ExecutorFactory { + + Executor executor(); + + static final ExecutorFactory DEFAULT = Executor::new; +} diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Globals.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Globals.java new file mode 100644 index 00000000000..c1b56b24e0a --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Globals.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal; + +import java.util.Optional; +import java.util.function.Supplier; + +public final class Globals { + + private Globals() { + } + + Globals objectFactory(ObjectFactory v) { + checkMutable(); + objectFactory = Optional.ofNullable(v).orElse(ObjectFactory.DEFAULT); + return this; + } + + ObjectFactory objectFactory() { + return objectFactory; + } + + Globals executorFactory(ExecutorFactory v) { + return objectFactory(ObjectFactory.build(objectFactory).executorFactory(v).create()); + } + + public static int main(Supplier mainBody) { + if (INSTANCE.isBound()) { + return mainBody.get(); + } else { + return ScopedValue.where(INSTANCE, new Globals()).call(mainBody::get); + } + } + + public static Globals instance() { + return INSTANCE.orElse(DEFAULT); + } + + private void checkMutable() { + if (this == DEFAULT) { + throw new UnsupportedOperationException("Can't modify immutable instance"); + } + } + + private ObjectFactory objectFactory = ObjectFactory.DEFAULT; + + private static final ScopedValue INSTANCE = ScopedValue.newInstance(); + private static final Globals DEFAULT = new Globals(); +} diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/IOUtils.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/IOUtils.java index aac113d7777..08cf0c10982 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/IOUtils.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/IOUtils.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2012, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -26,12 +26,9 @@ package jdk.jpackage.internal; import java.io.IOException; -import java.io.PrintStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.util.ArrayList; -import java.util.List; import jdk.jpackage.internal.model.JPackageException; /** @@ -50,46 +47,6 @@ final class IOUtils { StandardCopyOption.COPY_ATTRIBUTES); } - public static void exec(ProcessBuilder pb) - throws IOException { - exec(pb, false, null, false, Executor.INFINITE_TIMEOUT); - } - - // timeout in seconds. -1 will be return if process timeouts. - public static void exec(ProcessBuilder pb, long timeout) - throws IOException { - exec(pb, false, null, false, timeout); - } - - static void exec(ProcessBuilder pb, boolean testForPresenceOnly, - PrintStream consumer, boolean writeOutputToFile, long timeout) - throws IOException { - exec(pb, testForPresenceOnly, consumer, writeOutputToFile, - timeout, false); - } - - static void exec(ProcessBuilder pb, boolean testForPresenceOnly, - PrintStream consumer, boolean writeOutputToFile, - long timeout, boolean quiet) throws IOException { - List output = new ArrayList<>(); - Executor exec = Executor.of(pb) - .setWriteOutputToFile(writeOutputToFile) - .setTimeout(timeout) - .setQuiet(quiet) - .setOutputConsumer(lines -> { - lines.forEach(output::add); - if (consumer != null) { - output.forEach(consumer::println); - } - }); - - if (testForPresenceOnly) { - exec.execute(); - } else { - exec.executeExpectSuccess(); - } - } - static void writableOutputDir(Path outdir) { if (!Files.isDirectory(outdir)) { try { @@ -103,15 +60,4 @@ final class IOUtils { throw new JPackageException(I18N.format("error.cannot-write-to-output-dir", outdir.toAbsolutePath())); } } - - public static long getPID(Process p) { - try { - return p.pid(); - } catch (UnsupportedOperationException ex) { - Log.verbose(ex); // Just log exception and ignore it. This method - // is used for verbose output, so not a problem - // if unsupported. - return -1; - } - } } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/JLinkRuntimeBuilder.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/JLinkRuntimeBuilder.java index 37f166a4e7c..f960f8dc2bf 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/JLinkRuntimeBuilder.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/JLinkRuntimeBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2015, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -22,13 +22,14 @@ * or visit www.oracle.com if you need additional information or have any * questions. */ + package jdk.jpackage.internal; + import static jdk.jpackage.internal.model.RuntimeBuilder.getDefaultModulePath; +import static jdk.jpackage.internal.util.function.ThrowingRunnable.toRunnable; import java.io.File; import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringWriter; import java.lang.module.Configuration; import java.lang.module.ModuleDescriptor; import java.lang.module.ModuleFinder; @@ -50,7 +51,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import jdk.internal.module.ModulePath; import jdk.jpackage.internal.model.AppImageLayout; -import jdk.jpackage.internal.model.JPackageException; import jdk.jpackage.internal.model.LauncherModularStartupInfo; import jdk.jpackage.internal.model.LauncherStartupInfo; import jdk.jpackage.internal.model.RuntimeBuilder; @@ -58,27 +58,15 @@ import jdk.jpackage.internal.model.RuntimeBuilder; final class JLinkRuntimeBuilder implements RuntimeBuilder { private JLinkRuntimeBuilder(List jlinkCmdLine) { - this.jlinkCmdLine = jlinkCmdLine; + this.jlinkCmdLine = Objects.requireNonNull(jlinkCmdLine); } @Override public void create(AppImageLayout appImageLayout) { - var args = new ArrayList(); - args.add("--output"); - args.add(appImageLayout.runtimeDirectory().toString()); - args.addAll(jlinkCmdLine); - - StringWriter writer = new StringWriter(); - PrintWriter pw = new PrintWriter(writer); - - int retVal = LazyLoad.JLINK_TOOL.run(pw, pw, args.toArray(String[]::new)); - String jlinkOut = writer.toString(); - - args.add(0, "jlink"); - Log.verbose(args, List.of(jlinkOut), retVal, -1); - if (retVal != 0) { - throw new JPackageException(I18N.format("error.jlink.failed", jlinkOut)); - } + toRunnable(Executor.of() + .toolProvider(LazyLoad.JLINK_TOOL) + .args("--output", appImageLayout.runtimeDirectory().toString()) + .args(jlinkCmdLine)::executeExpectSuccess).run(); } @Override diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Log.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Log.java index b14b4ab22ca..0f51fa166f9 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Log.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Log.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011, 2021, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2011, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -28,7 +28,6 @@ package jdk.jpackage.internal; import java.io.PrintWriter; import java.text.SimpleDateFormat; import java.util.Date; -import java.util.List; /** * Log @@ -105,29 +104,6 @@ public class Log { } } - public void verbose(List strings, - List output, int returnCode, long pid) { - if (verbose) { - StringBuilder sb = new StringBuilder(); - sb.append("Command [PID: "); - sb.append(pid); - sb.append("]:\n "); - - for (String s : strings) { - sb.append(" " + s); - } - verbose(sb.toString()); - if (output != null && !output.isEmpty()) { - sb = new StringBuilder("Output:"); - for (String s : output) { - sb.append("\n " + s); - } - verbose(sb.toString()); - } - verbose("Returned: " + returnCode + "\n"); - } - } - private String addTimestamp(String msg) { SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss.SSS"); Date time = new Date(System.currentTimeMillis()); @@ -177,9 +153,4 @@ public class Log { public static void verbose(Throwable t) { instance.get().verbose(t); } - - public static void verbose(List strings, List out, - int ret, long pid) { - instance.get().verbose(strings, out, ret, pid); - } } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ObjectFactory.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ObjectFactory.java new file mode 100644 index 00000000000..f1a83eb9eab --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ObjectFactory.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal; + +import java.util.Objects; +import java.util.Optional; +import jdk.jpackage.internal.util.CompositeProxy; + +interface ObjectFactory extends ExecutorFactory, RetryExecutorFactory { + + static ObjectFactory.Builder build() { + return new Builder(); + } + + static ObjectFactory.Builder build(ObjectFactory from) { + return build().initFrom(from); + } + + static final class Builder { + private Builder() { + } + + ObjectFactory create() { + return CompositeProxy.build().invokeTunnel(CompositeProxyTunnel.INSTANCE).create( + ObjectFactory.class, + Optional.ofNullable(executorFactory).orElse(ExecutorFactory.DEFAULT), + Optional.ofNullable(retryExecutorFactory).orElse(RetryExecutorFactory.DEFAULT)); + } + + Builder initFrom(ObjectFactory of) { + Objects.requireNonNull(of); + return executorFactory(of).retryExecutorFactory(of); + } + + Builder executorFactory(ExecutorFactory v) { + executorFactory = v; + return this; + } + + Builder retryExecutorFactory(RetryExecutorFactory v) { + retryExecutorFactory = v; + return this; + } + + private ExecutorFactory executorFactory; + private RetryExecutorFactory retryExecutorFactory; + } + + static final ObjectFactory DEFAULT = build().create(); +} diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/RetryExecutor.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/RetryExecutor.java deleted file mode 100644 index f806217e8f9..00000000000 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/RetryExecutor.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Oracle designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA - * or visit www.oracle.com if you need additional information or have any - * questions. - */ -package jdk.jpackage.internal; - -import java.io.IOException; -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Supplier; - -public final class RetryExecutor { - public RetryExecutor() { - setMaxAttemptsCount(5); - setAttemptTimeoutMillis(2 * 1000); - setWriteOutputToFile(false); - } - - public RetryExecutor setMaxAttemptsCount(int v) { - attempts = v; - return this; - } - - public RetryExecutor setAttemptTimeoutMillis(int v) { - timeoutMillis = v; - return this; - } - - public RetryExecutor saveOutput(boolean v) { - saveOutput = v; - return this; - } - - public List getOutput() { - return output; - } - - public RetryExecutor setWriteOutputToFile(boolean v) { - writeOutputToFile = v; - return this; - } - - public RetryExecutor setExecutorInitializer(Consumer v) { - executorInitializer = v; - return this; - } - - public void abort() { - aborted = true; - } - - public boolean isAborted() { - return aborted; - } - - static RetryExecutor retryOnKnownErrorMessage(String v) { - RetryExecutor result = new RetryExecutor(); - return result.setExecutorInitializer(exec -> { - exec.setOutputConsumer(output -> { - if (!output.anyMatch(v::equals)) { - result.abort(); - } - }); - }); - } - - public void execute(String cmdline[]) throws IOException { - executeLoop(() -> - Executor.of(cmdline).setWriteOutputToFile(writeOutputToFile)); - } - - public void execute(ProcessBuilder pb) throws IOException { - executeLoop(() -> - Executor.of(pb).setWriteOutputToFile(writeOutputToFile)); - } - - private void executeLoop(Supplier execSupplier) throws IOException { - aborted = false; - for (;;) { - if (aborted) { - break; - } - - try { - Executor exec = execSupplier.get().saveOutput(saveOutput); - if (executorInitializer != null) { - executorInitializer.accept(exec); - } - exec.executeExpectSuccess(); - if (saveOutput) { - output = exec.getOutput(); - } - break; - } catch (IOException ex) { - if (aborted || (--attempts) <= 0) { - throw ex; - } - } - - try { - Thread.sleep(timeoutMillis); - } catch (InterruptedException ex) { - Log.verbose(ex); - throw new RuntimeException(ex); - } - } - } - - private Consumer executorInitializer; - private boolean aborted; - private int attempts; - private int timeoutMillis; - private boolean saveOutput; - private List output; - private boolean writeOutputToFile; -} diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/RetryExecutorFactory.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/RetryExecutorFactory.java new file mode 100644 index 00000000000..3efb522abd4 --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/RetryExecutorFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal; + +import jdk.jpackage.internal.util.RetryExecutor; + +@FunctionalInterface +interface RetryExecutorFactory { + + RetryExecutor retryExecutor(Class exceptionType); + + static final RetryExecutorFactory DEFAULT = RetryExecutor::new; +} diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/SystemEnvironment.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/SystemEnvironment.java index de98e97c922..5d9a1d6d147 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/SystemEnvironment.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/SystemEnvironment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -24,5 +24,5 @@ */ package jdk.jpackage.internal; -public interface SystemEnvironment { +interface SystemEnvironment { } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ToolValidator.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ToolValidator.java index 13e87d5cfa6..9440aff3a53 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ToolValidator.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ToolValidator.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -121,32 +121,32 @@ final class ToolValidator { cmdline.addAll(args); } - boolean canUseTool[] = new boolean[1]; + boolean canUseTool = false; if (minimalVersion == null) { // No version check. - canUseTool[0] = true; + canUseTool = true; } - String[] version = new String[1]; + String version = null; try { - Executor.of(cmdline.toArray(String[]::new)).setQuiet(true).setOutputConsumer(lines -> { - if (versionParser != null && minimalVersion != null) { - version[0] = versionParser.apply(lines); - if (version[0] != null && minimalVersion.compareTo(version[0]) <= 0) { - canUseTool[0] = true; - } + var result = Executor.of(cmdline).setQuiet(true).saveOutput().execute(); + var lines = result.content(); + if (versionParser != null && minimalVersion != null) { + version = versionParser.apply(lines.stream()); + if (version != null && minimalVersion.compareTo(version) <= 0) { + canUseTool = true; } - }).execute(); + } } catch (IOException e) { return new ConfigException(I18N.format("error.tool-error", toolPath, e.getMessage()), null, e); } - if (canUseTool[0]) { + if (canUseTool) { // All good. Tool can be used. return null; } else if (toolOldVersionErrorHandler != null) { - return toolOldVersionErrorHandler.apply(toolPath, version[0]); + return toolOldVersionErrorHandler.apply(toolPath, version); } else { return new ConfigException( I18N.format("error.tool-old-version", toolPath, minimalVersion), diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/cli/Main.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/cli/Main.java index 9feb5b40944..519958d9ff7 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/cli/Main.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/cli/Main.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -45,6 +45,7 @@ import java.util.function.Supplier; import java.util.spi.ToolProvider; import jdk.internal.opt.CommandLine; import jdk.internal.util.OperatingSystem; +import jdk.jpackage.internal.Globals; import jdk.jpackage.internal.Log; import jdk.jpackage.internal.model.ConfigException; import jdk.jpackage.internal.model.JPackageException; @@ -56,7 +57,15 @@ import jdk.jpackage.internal.util.function.ExceptionBox; */ public final class Main { - public static final class Provider implements ToolProvider { + public record Provider(Supplier bundlingEnvSupplier) implements ToolProvider { + + public Provider { + Objects.requireNonNull(bundlingEnvSupplier); + } + + public Provider() { + this(Main::loadBundlingEnvironment); + } @Override public String name() { @@ -65,7 +74,7 @@ public final class Main { @Override public int run(PrintWriter out, PrintWriter err, String... args) { - return Main.run(out, err, args); + return Main.run(bundlingEnvSupplier, out, err, args); } @Override @@ -94,7 +103,23 @@ public final class Main { System.exit(run(out, err, args)); } - public static int run(PrintWriter out, PrintWriter err, String... args) { + static int run(PrintWriter out, PrintWriter err, String... args) { + return run(Main::loadBundlingEnvironment, out, err, args); + } + + static int run(Supplier bundlingEnvSupplier, PrintWriter out, PrintWriter err, String... args) { + return Globals.main(() -> { + return runWithGlobals(bundlingEnvSupplier, out, err, args); + }); + } + + private static int runWithGlobals( + Supplier bundlingEnvSupplier, + PrintWriter out, + PrintWriter err, + String... args) { + + Objects.requireNonNull(bundlingEnvSupplier); Objects.requireNonNull(args); for (String arg : args) { Objects.requireNonNull(arg); @@ -128,8 +153,7 @@ public final class Main { return preprocessStatus; } - final var bundlingEnv = ServiceLoader.load(CliBundlingEnvironment.class, - CliBundlingEnvironment.class.getClassLoader()).findFirst().orElseThrow(); + final var bundlingEnv = bundlingEnvSupplier.get(); final var parseResult = Utils.buildParser(OperatingSystem.current(), bundlingEnv).create().apply(mappedArgs.get()); @@ -285,4 +309,10 @@ public final class Main { private static String getVersion() { return System.getProperty("java.version"); } + + private static CliBundlingEnvironment loadBundlingEnvironment() { + return ServiceLoader.load( + CliBundlingEnvironment.class, + CliBundlingEnvironment.class.getClassLoader()).findFirst().orElseThrow(); + } } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties index c5ea583514f..e97bee79e6e 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2017, 2025, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2017, 2026, Oracle and/or its affiliates. All rights reserved. # DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. # # This code is free software; you can redistribute it and/or modify it @@ -96,7 +96,6 @@ error.tool-not-found.advice=Please install "{0}" error.tool-old-version=Can not find "{0}" {1} or newer error.tool-old-version.advice=Please install "{0}" {1} or newer -error.jlink.failed=jlink failed with: {0} error.blocked.option=jlink option [{0}] is not permitted in --jlink-options error.no.name=Name not specified with --name and cannot infer one from app-image error.no.name.advice=Specify name with --name diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/CommandLineFormat.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/CommandLineFormat.java new file mode 100644 index 00000000000..4d64030876f --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/CommandLineFormat.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal.util; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Formats command line arguments. + */ +public final class CommandLineFormat { + + public String format(List cmdline) { + return cmdline.stream().map(enquoter::applyTo).collect(Collectors.joining(" ")); + } + + public static CommandLineFormat platform() { + var format = new CommandLineFormat(); + format.enquoter = Enquoter.identity().setEnquotePredicate(Enquoter.QUOTE_IF_WHITESPACES).setQuoteChar('\''); + return format; + } + + private CommandLineFormat() { + } + + private Enquoter enquoter; + + public static final Function, String> DEFAULT = platform()::format; +} diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/CommandOutputControl.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/CommandOutputControl.java new file mode 100644 index 00000000000..e35439815b1 --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/CommandOutputControl.java @@ -0,0 +1,1904 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal.util; + +import static java.util.stream.Collectors.joining; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintStream; +import java.io.StringReader; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.IntPredicate; +import java.util.function.Predicate; +import java.util.spi.ToolProvider; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import jdk.jpackage.internal.util.function.ExceptionBox; +import jdk.jpackage.internal.util.function.ThrowingConsumer; +import jdk.jpackage.internal.util.function.ThrowingRunnable; + +/** + * Runs commands and processes their stdout and stderr streams. + *

+ * A command is either a subprocess represented by {@link ProcessBuilder} or a + * tool provided by {@link ToolProvider}. + *

+ * A command is executed synchronously, and the result of its execution is + * stored in a {@link Result} instance which captures the exit code and any + * saved output streams. + *

+ * Depending on the configuration, it can save the entire output stream, only + * the first line, or not save the output at all. Stdout and stderr streams can + * be configured independently. + *

+ * Output streams can be treated as either byte streams or character streams. + * + *

+ * The table below shows how different parameter combinations affect the content + * written to streams returned by {@link #dumpStdout()} and + * {@link #dumpStderr()} for subsequently executed tools, regardless of whether + * their output streams are saved, or for subprocesses when the output streams + * are saved: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
discardStdout(false) and discardStderr(false)discardStdout(false) and discardStderr(true)discardStdout(true) and discardStderr(false)
redirectStderr(true) and dumpOutput(true) + *

+ * dumpStdout(): STDOUT and STDERR interleaved + *

+ * dumpStderr(): unchanged

+ *

+ * dumpStdout(): STDOUT + *

+ * dumpStderr(): unchanged

+ *

+ * dumpStdout(): STDERR; + *

+ * dumpStderr(): unchanged

redirectStderr(false) and dumpOutput(true) + *

+ * dumpStdout(): STDOUT + *

+ * dumpStderr(): STDERR

+ *

+ * dumpStdout(): STDOUT + *

+ * dumpStderr(): unchanged

+ *

+ * dumpStdout(): unchanged + *

+ * dumpStderr(): STDERR

+ * + *

+ * The table below shows how different parameter combinations affect the content + * written to the native file descriptors associated with {@link System#out} and + * {@link System#err} for subsequently executed subprocesses when the output + * streams are not saved: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
discardStdout(false) and discardStderr(false)discardStdout(false) and discardStderr(true)discardStdout(true) and discardStderr(false)
redirectStderr(true) and dumpOutput(true) + *

+ * System.out: STDOUT and STDERR interleaved + *

+ * System.err: unchanged

+ *

+ * System.out: STDOUT + *

+ * System.err: unchanged

+ *

+ * System.out: STDERR; + *

+ * The command's STDERR will be written to the stream referenced by + * {@link #dumpStdout()} rather than to the underlying file descriptor + * associated with the Java process's STDOUT + *

+ * System.err: unchanged

redirectStderr(false) and dumpOutput(true) + *

+ * System.out: STDOUT + *

+ * System.err: STDERR

+ *

+ * System.out: STDOUT + *

+ * System.err: unchanged

+ *

+ * System.out: unchanged + *

+ * System.err: STDERR

+ * + *

+ * The table below shows how different parameter combinations affect the + * properties of {@link Result} objects returned by + * {@link #execute(ProcessBuilder, long)} or + * {@link #execute(ToolProvider, long, String...)} when processing character + * streams: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
saveOutput(true)saveFirstLineOfOutput()
redirectStderr(true) and discardStdout(false) and + * discardStderr(false) + *

+ * content(): STDOUT and STDERR interleaved + *

+ * findStdout(): {@code Optional.empty()} + *

+ * findStderr(): {@code Optional.empty()}

+ *

+ * content(): a single-item list containing the first line of interleaved STDOUT + * and STDERR if the command produced any output; otherwise, an empty list + *

+ * findStdout(): {@code Optional.empty()} + *

+ * findStderr(): {@code Optional.empty()}

redirectStderr(false) and discardStdout(false) and + * discardStderr(false) + *

+ * content(): STDOUT followed by STDERR + *

+ * stdout(): STDOUT + *

+ * stderr(): STDERR

+ *

+ * content(): a list containing at most two items: the first line of STDOUT (if + * the command produced any), followed by the first line of STDERR (if the + * command produced any) + *

+ * stdout(): The first line of STDOUT (if the command produced any); otherwise + * an empty list + *

+ * findStderr(): The first line of STDERR (if the command produced any); + * otherwise an empty list + *

redirectStderr(true) and discardStdout(false) and + * discardStderr(true) + *

+ * content(): STDOUT + *

+ * stdout(): The same as content() + *

+ * findStderr(): {@code Optional.empty()}

+ *

+ * content(): The first line of STDOUT (if the command produced any); otherwise + * an empty list + *

+ * stdout(): The same as content() + *

+ * findStderr(): {@code Optional.empty()}

redirectStderr(false) and discardStdout(false) and + * discardStderr(true) + *

+ * content(): STDOUT + *

+ * stdout(): The same as content() + *

+ * stderr(): an empty list

+ *

+ * content(): The first line of STDOUT (if the command produced any); otherwise + * an empty list + *

+ * stdout(): The same as content() + *

+ * stderr(): an empty list

redirectStderr(true) and discardStdout(true) and + * discardStderr(false) + *

+ * content(): STDERR + *

+ * stdout(): The same as content() + *

+ * findStderr(): {@code Optional.empty()}

+ *

+ * content(): The first line of STDERR (if the command produced any); otherwise + * an empty list + *

+ * stdout(): The same as content() + *

+ * findStderr(): {@code Optional.empty()}

redirectStderr(false) and discardStdout(true) and + * discardStderr(false) + *

+ * content(): STDERR + *

+ * findStdout(): an empty list + *

+ * stderr(): The same as content()

+ *

+ * content(): The first line of STDERR (if the command produced any); otherwise + * an empty list + *

+ * findStdout(): an empty list + *

+ * stderr(): The same as content()

+ *

+ * The table below shows how different parameter combinations affect the + * properties of {@link Result} objects returned by + * {@link #execute(ProcessBuilder, long)} or + * {@link #execute(ToolProvider, long, String...)} when processing byte streams: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
saveOutput(true) or saveFirstLineOfOutput()
redirectStderr(true) and discardStdout(false) and + * discardStderr(false) + *

+ * byteContent(): STDOUT and STDERR interleaved + *

+ * findByteStdout(): {@code Optional.empty()} + *

+ * findByteStderr(): {@code Optional.empty()}

redirectStderr(false) and discardStdout(false) and + * discardStderr(false) + *

+ * byteContent(): STDOUT followed by STDERR + *

+ * byteStdout(): STDOUT + *

+ * byteStderr(): STDERR

redirectStderr(true) and discardStdout(false) and + * discardStderr(true) + *

+ * byteContent(): STDOUT + *

+ * byteStdout(): The same as byteContent() + *

+ * findByteStderr(): {@code Optional.empty()}

redirectStderr(false) and discardStdout(false) and + * discardStderr(true) + *

+ * byteContent(): STDOUT + *

+ * byteStdout(): The same as byteContent() + *

+ * byteStderr(): an empty array

redirectStderr(true) and discardStdout(true) and + * discardStderr(false) + *

+ * byteContent(): STDERR + *

+ * byteStdout(): The same as byteContent() + *

+ * findByteStderr(): {@code Optional.empty()}

redirectStderr(false) and discardStdout(true) and + * discardStderr(false) + *

+ * byteContent(): STDERR + *

+ * findByteStdout(): an empty array + *

+ * byteStderr(): The same as byteContent()

+ */ +public final class CommandOutputControl { + + public CommandOutputControl() { + outputStreamsControl = new OutputStreamsControl(); + } + + private CommandOutputControl(CommandOutputControl other) { + flags = other.flags; + outputStreamsControl = other.outputStreamsControl.copy(); + dumpStdout = other.dumpStdout; + dumpStderr = other.dumpStderr; + charset = other.charset; + processListener = other.processListener; + } + + /** + * Specifies whether the full output produced by commands subsequently executed + * by this object will be saved. + *

+ * If {@code v} is {@code true}, both stdout and stderr streams will be saved; + * otherwise, they will not be saved. + *

+ * This setting is mutually exclusive with {@link #saveFirstLineOfOutput()}. + * + * @param v {@code true} to save the full stdout and stderr streams; + * {@code false} otherwise + * @return this + */ + public CommandOutputControl saveOutput(boolean v) { + return setOutputControl(v, OutputControlOption.SAVE_ALL); + } + + /** + * Returns whether this object will save the complete output of commands + * subsequently executed. + * + * @return {@code true} if this object will save the full output of commands it + * executes subsequently; {@code false} otherwise + */ + public boolean isSaveOutput() { + return outputStreamsControl.stdout().saveAll(); + } + + /** + * Specifies whether the first line of the output, combined from the stdout and + * stderr streams of commands subsequently executed by this object, will be + * saved. + *

+ * This setting is mutually exclusive with {@link #saveOutput(boolean)}. + * + * @return this + */ + public CommandOutputControl saveFirstLineOfOutput() { + return setOutputControl(true, OutputControlOption.SAVE_FIRST_LINE); + } + + /** + * Returns whether this object will save the first line of the output of + * commands subsequently executed. + * + * @return {@code true} if this object will save the first line of the output of + * commands it executes subsequently; {@code false} otherwise + */ + public boolean isSaveFirstLineOfOutput() { + return outputStreamsControl.stdout().saveFirstLine(); + } + + /** + * Specifies whether this object will dump the output streams from + * subsequently executed commands into the streams returned by + * {@link #dumpStdout()} and {@link #dumpStdout()} methods respectively. + *

+ * If this object is configured to redirect stderr of subsequently executed + * commands into their stdout ({@code redirectStderr(true)}), their output + * streams will be dumped into the stream returned by {@code dumpStdout()} + * method. Otherwise, their stdout and stderr streams will be dumped into the + * stream returned by {@code dumpStdout()} and {@code dumpStderr()} methods + * respectively. + * + * @param v if output streams from subsequently executed commands will be + * dumped into streams returned by {@code dumpStdout()} and + * {@code dumpStderr()} methods respectively + * + * @return this + * + * @see #redirectStderr(boolean) + * @see #dumpStdout() + * @see #dumpStderr() + */ + public CommandOutputControl dumpOutput(boolean v) { + setFlag(Flag.DUMP, v); + return setOutputControl(v, OutputControlOption.DUMP); + } + + /** + * Returns the value passed in the last call of {@link #dumpOutput(boolean)} + * method on this object, or {@code false} if the method has not been called. + * + * @return the value passed in the last call of {@link #dumpOutput(boolean)} + */ + public boolean isDumpOutput() { + return Flag.DUMP.isSet(flags); + } + + /** + * Specifies whether this object will treat output streams of subsequently + * executed commands as byte streams rather than character streams. + * + * @param v {@code true} if this object will treat the output streams of + * subsequently executed commands as byte streams, and {@code false} + * otherwise + * + * @return this + */ + public CommandOutputControl binaryOutput(boolean v) { + return setFlag(Flag.BINARY_OUTPUT, v); + } + + /** + * Returns the value passed in the last call of {@link #binaryOutput(boolean)} + * method on this object, or {@code false} if the method has not been called. + * + * @return the value passed in the last call of {@link #binaryOutput(boolean)} + */ + public boolean isBinaryOutput() { + return Flag.BINARY_OUTPUT.isSet(flags); + } + + /** + * Sets character encoding that will be applied to the stdout and the stderr + * streams of commands (subprocesses and {@code ToolProvider}-s) subsequently + * executed by this object. The default encoding is {@code UTF-8}. + *

+ * The value will be ignored if this object is configured for byte output + * streams. + * + * @param v character encoding for output streams of subsequently executed + * commands + * + * @see #binaryOutput(boolean) + * + * @return this + */ + public CommandOutputControl charset(Charset v) { + charset = v; + return this; + } + + /** + * Returns the value passed in the last call of + * {@link #charset(Charset)} method on this object, or + * {@link StandardCharsets#UTF_8} if the method has not been called. + * + * @return the character encoding that will be applied to the stdout and stderr + * streams of commands subsequently executed by this object + */ + public Charset charset() { + return Optional.ofNullable(charset).orElse(StandardCharsets.UTF_8); + } + + /** + * Specifies whether the stderr stream will be redirected into the stdout stream + * for commands subsequently executed by this object. + * + * @see ProcessBuilder#redirectErrorStream(boolean) + * + * @param v {@code true} if the stderr stream of commands subsequently executed + * by this object will be redirected into the stdout stream; + * {@code false} otherwise + * + * @return this + */ + public CommandOutputControl redirectStderr(boolean v) { + return setFlag(Flag.REDIRECT_STDERR, v); + } + + /** + * Returns the value passed in the last call of {@link #redirectStderr(boolean)} + * method on this object, or {@code false} if the method has not been called. + * + * @return the value passed in the last call of {@link #redirectStderr(boolean)} + */ + public boolean isRedirectStderr() { + return Flag.REDIRECT_STDERR.isSet(flags); + } + + /** + * Specifies whether stderr and stdout streams for subprocesses subsequently + * executed by this object will be stored in files. + *

+ * By default, if an output stream of a subprocess is configured for saving, + * this object will retrieve the content using {@link Process#getInputStream()} + * function for stdout and {@link Process#getErrorStream()} function for stderr. + * However, these functions don't always work correctly due to a + * JDK-8236825 bug + * still reproducible on macOS JDK26. The alternative way to get the content of + * output streams of a subprocess is to redirect them into files and read these + * files when the subprocess terminates. + *

+ * It will use {@code Files.createTempFile("jpackageOutputTempFile", ".tmp")} to + * create a file for each subprocess's output stream configured for saving. All + * created files will be automatically deleted at the exit of + * {@link #execute(ProcessBuilder, long)} method. + *

+ * Doesn't apply to executing {@code ToolProvider}-s. + *

+ * Storing output streams in files takes longer than managing them in memory and + * should be avoided if possible. + * + * @param v {@code true} if this object will use files to store saved output + * streams of subsequently executed subprocesses; {@code false} + * otherwise + * @return this + */ + public CommandOutputControl storeOutputInFiles(boolean v) { + return setFlag(Flag.STORE_OUTPUT_IN_FILES, v); + } + + /** + * Returns the value passed in the last call of {@link #storeOutputInFiles(boolean)} + * method on this object, or {@code false} if the method has not been called. + * + * @return the value passed in the last call of {@link #storeOutputInFiles(boolean)} + */ + public boolean isStoreOutputInFiles() { + return Flag.STORE_OUTPUT_IN_FILES.isSet(flags); + } + + /** + * Specifies whether stdout streams from commands subsequently executed by this + * object will be discarded. + * + * @param v {@code true} if this object will discard stdout streams from + * commands subsequently executed by this object; {@code false} + * otherwise + * @return this + */ + public CommandOutputControl discardStdout(boolean v) { + setFlag(Flag.DISCARD_STDOUT, v); + outputStreamsControl.stdout().discard(v); + return this; + } + + /** + * Returns the value passed in the last call of {@link #discardStdout(boolean)} + * method on this object, or {@code false} if the method has not been called. + * + * @return the value passed in the last call of {@link #discardStdout(boolean)} + */ + public boolean isDiscardStdout() { + return Flag.DISCARD_STDOUT.isSet(flags); + } + + /** + * Specifies whether stderr streams from commands subsequently executed by this + * object will be discarded. + * + * @param v {@code true} if this object will discard stderr streams from + * commands subsequently executed by this object; {@code false} + * otherwise + * @return this + */ + public CommandOutputControl discardStderr(boolean v) { + setFlag(Flag.DISCARD_STDERR, v); + outputStreamsControl.stderr().discard(v); + return this; + } + + /** + * Returns the value passed in the last call of {@link #discardStderr(boolean)} + * method on this object, or {@code false} if the method has not been called. + * + * @return the value passed in the last call of {@link #discardStderr(boolean)} + */ + public boolean isDiscardStderr() { + return Flag.DISCARD_STDERR.isSet(flags); + } + + /** + * Specifies the stream where stdout streams from commands subsequently executed + * by this object will be dumped. + *

+ * If the {@code null} is specified and this object configuration is equivalent + * to {@code dumpOutput(true).saveOutput(false).discardStdout(false)} the stdout + * streams from commands subsequently executed by this object will be written + * into the file descriptor associated with the {@code Systsem.out} stream. If + * you want them to be written into the {@code Systsem.out} object, pass the + * {@code Systsem.out} reference into this function. + * + * @param v the stream where stdout streams from commands subsequently executed + * by this object will be dumped; {@code null} permitted + * @return this + */ + public CommandOutputControl dumpStdout(PrintStream v) { + dumpStdout = v; + return this; + } + + /** + * Returns the value passed in the last call of {@link #dumpStdout(PrintStream)} + * method on this object, or {@link System#out} if the method has not been + * called. + * + * @return the stream where stdout streams from commands subsequently executed + * by this object will be dumped + */ + public PrintStream dumpStdout() { + return Optional.ofNullable(dumpStdout).orElse(System.out); + } + + /** + * Specifies the stream where stderr streams from commands subsequently executed + * by this object will be dumped. + *

+ * If the {@code null} is specified and this object configuration is equivalent + * to + * {@code dumpOutput(true).saveOutput(false).redirectStderr(false).discardStderr(false)} + * the stderr streams from commands subsequently executed by this object will be + * written into the file descriptor associated with the {@code Systsem.err} + * stream. If you want them to be written into the {@code Systsem.err} object, + * pass the {@code Systsem.err} reference into this function. + * + * @param v the stream where stderr streams from commands subsequently executed + * by this object will be dumped; {@code null} permitted + * @return this + */ + public CommandOutputControl dumpStderr(PrintStream v) { + dumpStderr = v; + return this; + } + + /** + * Returns the value passed in the last call of {@link #dumpStderr(PrintStream)} + * method on this object, or {@link System#err} if the method has not been + * called. + * + * @return the stream where stderr streams from commands subsequently executed + * by this object will be dumped + */ + public PrintStream dumpStderr() { + return Optional.ofNullable(dumpStderr).orElse(System.err); + } + + /** + * Sets the callback to be invoked when this object starts a subprocess from + * subsequent {@link #execute(ProcessBuilder, long)} calls. + * + *

+ * This object maintains a single callback. Calling this method replaces any + * previously set callback. + * + *

+ * The callback is invoked on the thread that calls + * {@link #execute(ProcessBuilder, long)} after the subprocess's output streams + * begin being pumped. + * + * @param v the callback for notifying a subprocess being started or + * {@code null} + * @return this + */ + public CommandOutputControl processListener(Consumer v) { + processListener = v; + return this; + } + + /** + * Returns an {@code Optional} wrapping the value passed in the last call of + * {@link #processListener(Consumer)} method on this object, or an empty + * {@code Optional} if the method has not been called or {@code null} was passed in the last call. + * + * @return an {@code Optional} wrapping the value passed in the last call of + * {@link #processListener(Consumer)} + */ + public Optional> processListener() { + return Optional.ofNullable(processListener); + } + + /** + * Returns a deep copy of this object. Changes to the copy will not affect the + * original. + * + * @return a deep copy of this object + */ + public CommandOutputControl copy() { + return new CommandOutputControl(this); + } + + public interface ExecutableAttributes { + List commandLine(); + } + + public sealed interface Executable { + + ExecutableAttributes attributes(); + + Result execute() throws IOException, InterruptedException; + + Result execute(long timeout, TimeUnit unit) throws IOException, InterruptedException; + } + + public record ProcessAttributes(Optional pid, List commandLine) implements ExecutableAttributes { + public ProcessAttributes { + Objects.requireNonNull(pid); + commandLine.forEach(Objects::requireNonNull); + } + + @Override + public String toString() { + return CommandLineFormat.DEFAULT.apply(commandLine()); + } + } + + public record ToolProviderAttributes(String name, List args) implements ExecutableAttributes { + public ToolProviderAttributes { + Objects.requireNonNull(name); + args.forEach(Objects::requireNonNull); + } + + @Override + public String toString() { + return CommandLineFormat.DEFAULT.apply(commandLine()); + } + + @Override + public List commandLine() { + return Stream.concat(Stream.of(name), args.stream()).toList(); + } + } + + public static ExecutableAttributes EMPTY_EXECUTABLE_ATTRIBUTES = new ExecutableAttributes() { + @Override + public String toString() { + return ""; + } + + @Override + public List commandLine() { + return List.of(); + } + }; + + public Executable createExecutable(ToolProvider tp, String... args) { + return new ToolProviderExecutable(tp, List.of(args), this); + } + + public Executable createExecutable(ProcessBuilder pb) { + return new ProcessExecutable(pb, this); + } + + public record Result( + Optional exitCode, + Optional>> output, + Optional> byteOutput, + ExecutableAttributes execAttrs) { + + public Result { + Objects.requireNonNull(exitCode); + Objects.requireNonNull(output); + Objects.requireNonNull(byteOutput); + Objects.requireNonNull(execAttrs); + } + + public Result(int exitCode) { + this(Optional.of(exitCode), Optional.empty(), Optional.empty(), EMPTY_EXECUTABLE_ATTRIBUTES); + } + + public int getExitCode() { + return exitCode.orElseThrow(() -> { + return new IllegalStateException("Exit code is unavailable for timed-out command"); + }); + } + + public Result expectExitCode(int main, int... other) throws UnexpectedExitCodeException { + return expectExitCode(v -> { + return IntStream.concat(IntStream.of(main), IntStream.of(other)).boxed().anyMatch(Predicate.isEqual(v)); + }); + } + + public Result expectExitCode(Collection expected) throws UnexpectedExitCodeException { + return expectExitCode(expected::contains); + } + + public Result expectExitCode(IntPredicate expected) throws UnexpectedExitCodeException { + if (!expected.test(getExitCode())) { + throw new UnexpectedExitCodeException(this); + } + return this; + } + + public UnexpectedResultException unexpected() { + return new UnexpectedResultException(this); + } + + public UnexpectedResultException unexpected(String message) { + return new UnexpectedResultException(this, message); + } + + public Optional> findContent() { + return output.flatMap(CommandOutput::combined); + } + + public Optional> findStdout() { + return output.flatMap(CommandOutput::stdout); + } + + public Optional> findStderr() { + return output.flatMap(CommandOutput::stderr); + } + + // For backward compatibility + public List getOutput() { + return content(); + } + + public List content() { + return findContent().orElseThrow(); + } + + public List stdout() { + return findStdout().orElseThrow(); + } + + public List stderr() { + return findStderr().orElseThrow(); + } + + public Optional findByteContent() { + return byteOutput.flatMap(CommandOutput::combined); + } + + public Optional findByteStdout() { + return byteOutput.flatMap(CommandOutput::stdout); + } + + public Optional findByteStderr() { + return byteOutput.flatMap(CommandOutput::stderr); + } + + public byte[] byteContent() { + return findByteContent().orElseThrow(); + } + + public byte[] byteStdout() { + return findByteStdout().orElseThrow(); + } + + public byte[] byteStderr() { + return findByteStderr().orElseThrow(); + } + + public Result toCharacterResult(Charset charset, boolean keepByteContent) throws IOException { + Objects.requireNonNull(charset); + + if (byteOutput.isEmpty()) { + return this; + } + + var theByteOutput = byteOutput.get(); + + try { + Optional>> out; + if (theByteOutput.content().isEmpty()) { + // The content is unavailable. + out = Optional.empty(); + } else if (theByteOutput.stdoutContentSize() == 0) { + // The content is available, but empty. + out = Optional.of(new StringListContent(List.of())); + } else if (theByteOutput.interleaved()) { + // STDOUT and STDERR streams are interleaved. + out = theByteOutput.combined().map(data -> { + return toStringList(data, charset); + }); + } else { + // Non-empty STDOUT not interleaved with STDERR. + out = findByteStdout().map(data -> { + return toStringList(data, charset); + }); + } + + var err = findByteStderr().map(data -> { + return toStringList(data, charset); + }); + + var newOutput = combine(out, err, theByteOutput.interleaved); + + return new Result(exitCode, Optional.of(newOutput), byteOutput.filter(_ -> keepByteContent), execAttrs); + } catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + + public Result copyWithExecutableAttributes(ExecutableAttributes execAttrs) { + return new Result(exitCode, output, byteOutput, Objects.requireNonNull(execAttrs)); + } + + private static StringListContent toStringList(byte[] data, Charset charset) { + try (var bufReader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(data), charset))) { + return new StringListContent(bufReader.lines().toList()); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + } + + public static sealed class UnexpectedResultException extends IOException { + + private UnexpectedResultException(Result value, String message) { + super(Objects.requireNonNull(message)); + this.value = Objects.requireNonNull(value); + } + + private UnexpectedResultException(Result value) { + this(value, String.format("Unexpected result from executing the command %s", value.execAttrs())); + } + + public Result getResult() { + return value; + } + + private final transient Result value; + + private static final long serialVersionUID = 1L; + } + + public static final class UnexpectedExitCodeException extends UnexpectedResultException { + + public UnexpectedExitCodeException(Result value, String message) { + super(value, message); + } + + public UnexpectedExitCodeException(Result value) { + this(value, String.format("Unexpected exit code %d from executing the command %s", value.getExitCode(), value.execAttrs())); + } + + private static final long serialVersionUID = 1L; + } + + public String description() { + var tokens = outputStreamsControl.descriptionTokens(); + if (isBinaryOutput()) { + tokens.add("byte"); + } + if (redirectRetainedStderr()) { + tokens.add("interleave"); + } + return String.join("; ", tokens); + } + + private Result execute(ProcessBuilder pb, long timeoutMillis) + throws IOException, InterruptedException { + + Objects.requireNonNull(pb); + + var theCharset = charset(); + + configureProcessBuilder(pb); + + var csc = new CachingStreamsConfig(); + + var process = pb.start(); + + BiConsumer gobbler = (in, ps) -> { + try { + if (isBinaryOutput()) { + try (in) { + in.transferTo(ps); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } else { + try (var bufReader = new BufferedReader(new InputStreamReader(in, theCharset))) { + bufReader.lines().forEach(ps::println); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + } finally { + suppressIOException(ps::flush); + } + }; + + // Start fetching process output streams. + // Do it before waiting for the process termination to avoid deadlocks. + + final Optional> stdoutGobbler; + if (mustReadOutputStream(pb.redirectOutput())) { + stdoutGobbler = Optional.of(CompletableFuture.runAsync(() -> { + gobbler.accept(process.getInputStream(), csc.out()); + }, gobblerExecutor)); + } else { + stdoutGobbler = Optional.empty(); + } + + final Optional> stderrGobbler; + if (!pb.redirectErrorStream() && mustReadOutputStream(pb.redirectError())) { + stderrGobbler = Optional.of(CompletableFuture.runAsync(() -> { + gobbler.accept(process.getErrorStream(), csc.err()); + }, gobblerExecutor)); + } else { + stderrGobbler = Optional.empty(); + } + + processListener().ifPresent(c -> { + c.accept(process); + }); + + final Optional exitCode; + if (timeoutMillis < 0) { + exitCode = Optional.of(process.waitFor()); + } else if (!process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) { + // Destroy the process and cancel the process output stream gobblers. + process.destroy(); + for (var g : List.of(stdoutGobbler, stderrGobbler)) { + g.ifPresent(future -> { + future.cancel(true); + }); + } + exitCode = Optional.empty(); + } else { + exitCode = Optional.of(process.exitValue()); + } + + try { + if (isStoreOutputInFiles()) { + var stdoutStorage = streamFileSink(pb.redirectOutput()); + var stderrStorage = streamFileSink(pb.redirectError()); + + Function toInputStream = path -> { + try { + return Files.newInputStream(path); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }; + + try { + stdoutStorage.map(toInputStream).ifPresent(in -> { + gobbler.accept(in, csc.out()); + }); + + stderrStorage.map(toInputStream).ifPresent(in -> { + gobbler.accept(in, csc.err()); + }); + } finally { + Consumer silentDeleter = path -> { + suppressIOException(Files::delete, path); + }; + + stdoutStorage.ifPresent(silentDeleter); + stderrStorage.ifPresent(silentDeleter); + } + } else { + stdoutGobbler.ifPresent(CommandOutputControl::join); + stderrGobbler.ifPresent(CommandOutputControl::join); + } + } catch (UncheckedIOException ex) { + throw ex.getCause(); + } + + return csc.createResult(exitCode, new ProcessAttributes(getPID(process), pb.command())); + } + + private Result execute(ToolProvider tp, long timeoutMillis, String... args) + throws IOException, InterruptedException { + + var csc = new CachingStreamsConfig(); + + Optional exitCode; + var out = csc.out(); + var err = csc.err(); + try { + if (timeoutMillis < 0) { + exitCode = Optional.of(tp.run(out, err, args)); + } else { + var future = new CompletableFuture>(); + + var workerThread = Thread.ofVirtual().start(() -> { + Optional result = Optional.empty(); + try { + result = Optional.of(tp.run(out, err, args)); + } catch (Exception ex) { + future.completeExceptionally(ex); + return; + } + future.complete(result); + }); + + try { + exitCode = future.get(timeoutMillis, TimeUnit.MILLISECONDS); + } catch (ExecutionException ex) { + // Rethrow the cause (ex.getCause()) as a RuntimeException. + // If `ex.getCause()` returns an Error, ExceptionBox.unbox() will throw it. + throw ExceptionBox.toUnchecked(ExceptionBox.unbox(ex.getCause())); + } catch (TimeoutException ex) { + workerThread.interrupt(); + exitCode = Optional.empty(); + } + } + } finally { + suppressIOException(out::flush); + suppressIOException(err::flush); + } + + return csc.createResult(exitCode, new ToolProviderAttributes(tp.name(), List.of(args))); + } + + private CommandOutputControl setOutputControl(boolean set, OutputControlOption v) { + outputStreamsControl.stdout().set(set, v); + outputStreamsControl.stderr().set(set, v); + return this; + } + + private CommandOutputControl setFlag(Flag flag, boolean v) { + flags = flag.set(flags, v); + return this; + } + + private Optional streamFileSink(ProcessBuilder.Redirect redirect) { + return Optional.of(redirect) + .filter(Predicate.isEqual(ProcessBuilder.Redirect.DISCARD).negate()) + .map(ProcessBuilder.Redirect::file) + .map(File::toPath); + } + + private void configureProcessBuilder(ProcessBuilder pb) throws IOException { + + var stdoutRedirect = outputStreamsControl.stdout().asProcessBuilderRedirect(); + var stderrRedirect = outputStreamsControl.stderr().asProcessBuilderRedirect(); + + if (!stdoutRedirect.equals(stderrRedirect) && Stream.of( + stdoutRedirect, + stderrRedirect + ).noneMatch(Predicate.isEqual(ProcessBuilder.Redirect.DISCARD)) && redirectRetainedStderr()) { + throw new IllegalStateException(String.format( + "Can't redirect stderr into stdout because they have different redirects: stdout=%s; stderr=%s", + stdoutRedirect, stderrRedirect)); + } + + pb.redirectErrorStream(redirectRetainedStderr()); + if (replaceStdoutWithStderr()) { + if (stderrRedirect.equals(ProcessBuilder.Redirect.INHERIT)) { + stderrRedirect = ProcessBuilder.Redirect.PIPE; + } + pb.redirectErrorStream(false); + } + + stdoutRedirect = mapRedirect(stdoutRedirect); + stderrRedirect = mapRedirect(stderrRedirect); + + if (dumpStdout != null && stdoutRedirect.equals(ProcessBuilder.Redirect.INHERIT)) { + stdoutRedirect = ProcessBuilder.Redirect.PIPE; + } + + if (dumpStderr != null && stderrRedirect.equals(ProcessBuilder.Redirect.INHERIT)) { + stderrRedirect = ProcessBuilder.Redirect.PIPE; + } + + pb.redirectOutput(stdoutRedirect); + pb.redirectError(stderrRedirect); + } + + private ProcessBuilder.Redirect mapRedirect(ProcessBuilder.Redirect redirect) throws IOException { + if (isStoreOutputInFiles() && redirect.equals(ProcessBuilder.Redirect.PIPE)) { + var sink = Files.createTempFile("jpackageOutputTempFile", ".tmp"); + return ProcessBuilder.Redirect.to(sink.toFile()); + } else { + return redirect; + } + } + + /** + * Returns {@code true} if STDERR is not discarded and will be redirected to STDOUT, and {@code false} otherwise. + */ + private boolean redirectRetainedStderr() { + return isRedirectStderr() && !outputStreamsControl.stderr().discard(); + } + + /** + * Returns {@code true} if STDERR will replace STDOUT, and {@code false} otherwise. + *

+ * STDERR will replace STDOUT if it is redirected and not discarded, and if STDOUT is discarded. + */ + private boolean replaceStdoutWithStderr() { + return redirectRetainedStderr() && outputStreamsControl.stdout().discard(); + } + + private static T join(CompletableFuture future, T cancelledValue) { + Objects.requireNonNull(future); + try { + return future.join(); + } catch (CancellationException ex) { + return cancelledValue; + } catch (CompletionException ex) { + switch (ExceptionBox.unbox(ex.getCause())) { + case IOException cause -> { + throw new UncheckedIOException(cause); + } + case UncheckedIOException cause -> { + throw cause; + } + case Exception cause -> { + throw ExceptionBox.toUnchecked(cause); + } + } + } + } + + private static void join(CompletableFuture future) { + join(future, null); + } + + private static boolean mustReadOutputStream(ProcessBuilder.Redirect redirect) { + return redirect.equals(ProcessBuilder.Redirect.PIPE); + } + + private static Optional> read(OutputControl outputControl, CachingPrintStream cps) throws IOException { + final var bufferAsString = cps.bufferContents(); + try (final var bufReader = new BufferedReader(new StringReader(bufferAsString.orElse("")))) { + if (outputControl.saveFirstLine()) { + return Optional.of(bufReader.lines().findFirst().map(List::of).orElseGet(List::of)); + } else if (outputControl.saveAll()) { + return Optional.of(bufReader.lines().toList()); + } else { + return Optional.empty(); + } + } catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + + private static Optional readBinary(OutputControl outputControl, CachingPrintStream cps) { + if (outputControl.save()) { + return cps.buf().map(ByteArrayOutputStream::toByteArray).or(() -> { + return Optional.of(new byte[0]); + }); + } else { + return Optional.empty(); + } + } + + private static CommandOutput combine( + Optional> out, + Optional> err, + boolean interleaved) { + + if (out.isEmpty() && err.isEmpty()) { + return CommandOutput.empty(); + } else if (out.isEmpty()) { + // This branch is unreachable because it is impossible to make it save stderr without saving stdout. + // If streams are configured for saving and stdout is discarded, + // its saved contents will be an Optional instance wrapping an empty content, not an empty Optional. + throw ExceptionBox.reachedUnreachable(); + } else if (err.isEmpty()) { + return new CommandOutput<>(out, Integer.MAX_VALUE, interleaved); + } else { + final var combined = out.get().append(err.get()); + return new CommandOutput<>(Optional.of(combined), out.orElseThrow().size(), interleaved); + } + } + + private static PrintStream nullPrintStream() { + return new PrintStream(OutputStream.nullOutputStream()); + } + + private sealed interface Content { + T data(); + int size(); + Content slice(int from, int to); + Content append(Content other); + } + + private record StringListContent(List data) implements Content> { + StringListContent { + Objects.requireNonNull(data); + } + + @Override + public int size() { + return data.size(); + } + + @Override + public StringListContent slice(int from, int to) { + return new StringListContent(data.subList(from, to)); + } + + @Override + public StringListContent append(Content> other) { + return new StringListContent(Stream.of(data, other.data()).flatMap(List::stream).toList()); + } + } + + private record ByteContent(byte[] data) implements Content { + ByteContent { + Objects.requireNonNull(data); + } + + @Override + public int size() { + return data.length; + } + + @Override + public ByteContent slice(int from, int to) { + return new ByteContent(Arrays.copyOfRange(data, from, to)); + } + + @Override + public ByteContent append(Content other) { + byte[] combined = new byte[size() + other.size()]; + System.arraycopy(data, 0, combined, 0, data.length); + System.arraycopy(other.data(), 0, combined, data.length, other.size()); + return new ByteContent(combined); + } + } + + private record OutputStreamsControl(OutputControl stdout, OutputControl stderr) { + OutputStreamsControl { + Objects.requireNonNull(stdout); + Objects.requireNonNull(stderr); + } + + OutputStreamsControl() { + this(new OutputControl(), new OutputControl()); + } + + OutputStreamsControl copy() { + return new OutputStreamsControl(stdout.copy(), stderr.copy()); + } + + List descriptionTokens() { + final List tokens = new ArrayList<>(); + if (stdout.save()) { // Save flags are the same for stdout and stderr, checking stdout is sufficient. + streamsLabel("save ", true).ifPresent(tokens::add); + } + if (stdout.dump() || stderr.dump()) { + streamsLabel("echo ", true).ifPresent(tokens::add); + } + streamsLabel("discard ", false).ifPresent(tokens::add); + if (tokens.isEmpty()) { + // Unreachable because there is always at least one token in the description. + throw ExceptionBox.reachedUnreachable(); + } else { + return tokens; + } + } + + private Optional streamsLabel(String prefix, boolean negate) { + Objects.requireNonNull(prefix); + final var str = Stream.of(stdoutLabel(negate), stderrLabel(negate)) + .filter(Optional::isPresent) + .map(Optional::orElseThrow) + .collect(joining("+")); + if (str.isEmpty()) { + return Optional.empty(); + } else { + return Optional.of(prefix + str); + } + } + + private Optional stdoutLabel(boolean negate) { + if ((stdout.discard() && !negate) || (!stdout.discard() && negate)) { + return Optional.of("out"); + } else { + return Optional.empty(); + } + } + + private Optional stderrLabel(boolean negate) { + if ((stderr.discard() && !negate) || (!stderr.discard() && negate)) { + return Optional.of("err"); + } else { + return Optional.empty(); + } + } + } + + private record CachingPrintStream(PrintStream ps, Optional buf) { + CachingPrintStream { + Objects.requireNonNull(ps); + Objects.requireNonNull(buf); + } + + Optional bufferContents() { + return buf.map(ByteArrayOutputStream::toString); + } + + static Builder build(Charset charset) { + return new Builder(charset); + } + + static final class Builder { + + private Builder(Charset charset) { + this.charset = Objects.requireNonNull(charset); + } + + Builder save(boolean v) { + save = v; + return this; + } + + Builder discard(boolean v) { + discard = v; + return this; + } + + Builder dumpStream(PrintStream v) { + dumpStream = v; + return this; + } + + Builder buffer(ByteArrayOutputStream v) { + externalBuffer = v; + return this; + } + + CachingPrintStream create() { + final Optional buf; + if (save && !discard) { + buf = Optional.ofNullable(externalBuffer).or(() -> { + return Optional.of(new ByteArrayOutputStream()); + }); + } else { + buf = Optional.empty(); + } + + final PrintStream ps; + if (buf.isPresent() && dumpStream != null) { + ps = new PrintStream(new TeeOutputStream(List.of(buf.get(), dumpStream)), true, dumpStream.charset()); + } else if (!discard) { + ps = buf.map(in -> { + return new PrintStream(in, false, charset); + }).or(() -> { + return Optional.ofNullable(dumpStream); + }).orElseGet(CommandOutputControl::nullPrintStream); + } else { + ps = nullPrintStream(); + } + + return new CachingPrintStream(ps, buf); + } + + private boolean save; + private boolean discard; + private PrintStream dumpStream; + private ByteArrayOutputStream externalBuffer; + private final Charset charset; + } + } + + private final class CachingStreamsConfig { + + CachingStreamsConfig() { + out = outputStreamsControl.stdout().buildCachingPrintStream(dumpStdout(), charset()).create(); + if (isRedirectStderr()) { + var builder = outputStreamsControl.stderr().buildCachingPrintStream(dumpStdout(), charset()); + out.buf().ifPresent(builder::buffer); + err = builder.create(); + } else { + err = outputStreamsControl.stderr().buildCachingPrintStream(dumpStderr(), charset()).create(); + } + } + + Result createResult(Optional exitCode, ExecutableAttributes execAttrs) throws IOException { + + CommandOutput> output; + CommandOutput byteOutput; + + CachingPrintStream effectiveOut; + if (out.buf().isEmpty() && isRedirectStderr()) { + effectiveOut = new CachingPrintStream(nullPrintStream(), err.buf()); + } else { + effectiveOut = out; + } + + if (isBinaryOutput()) { + Optional outContent, errContent; + if (isRedirectStderr()) { + outContent = readBinary(outputStreamsControl.stdout(), effectiveOut).map(ByteContent::new); + errContent = Optional.empty(); + } else { + outContent = readBinary(outputStreamsControl.stdout(), out).map(ByteContent::new); + errContent = readBinary(outputStreamsControl.stderr(), err).map(ByteContent::new); + } + + byteOutput = combine(outContent, errContent, redirectRetainedStderr()); + output = null; + } else { + Optional outContent, errContent; + if (isRedirectStderr()) { + outContent = read(outputStreamsControl.stdout(), effectiveOut).map(StringListContent::new); + errContent = Optional.empty(); + } else { + outContent = read(outputStreamsControl.stdout(), out).map(StringListContent::new); + errContent = read(outputStreamsControl.stderr(), err).map(StringListContent::new); + } + + output = combine(outContent, errContent, redirectRetainedStderr()); + byteOutput = null; + } + + return new Result(exitCode, Optional.ofNullable(output), Optional.ofNullable(byteOutput), execAttrs); + } + + PrintStream out() { + return out.ps(); + } + + PrintStream err() { + return err.ps(); + } + + private final CachingPrintStream out; + private final CachingPrintStream err; + } + + private static final class OutputControl { + + OutputControl() { + } + + private OutputControl(OutputControl other) { + dump = other.dump; + discard = other.discard; + save = other.save; + } + + boolean save() { + return save.isPresent(); + } + + boolean saveAll() { + return save.orElse(null) == OutputControlOption.SAVE_ALL; + } + + boolean saveFirstLine() { + return save.orElse(null) == OutputControlOption.SAVE_FIRST_LINE; + } + + boolean discard() { + return discard || (!dump && save.isEmpty()); + } + + boolean dump() { + return !discard && dump; + } + + OutputControl dump(boolean v) { + this.dump = v; + return this; + } + + OutputControl discard(boolean v) { + this.discard = v; + return this; + } + + OutputControl saveAll(boolean v) { + if (v) { + save = Optional.of(OutputControlOption.SAVE_ALL); + } else { + save = Optional.empty(); + } + return this; + } + + OutputControl saveFirstLine(boolean v) { + if (v) { + save = Optional.of(OutputControlOption.SAVE_FIRST_LINE); + } else { + save = Optional.empty(); + } + return this; + } + + OutputControl set(boolean set, OutputControlOption v) { + switch (v) { + case DUMP -> dump(set); + case SAVE_ALL -> saveAll(set); + case SAVE_FIRST_LINE -> saveFirstLine(set); + } + return this; + } + + OutputControl copy() { + return new OutputControl(this); + } + + ProcessBuilder.Redirect asProcessBuilderRedirect() { + if (discard()) { + return ProcessBuilder.Redirect.DISCARD; + } else if (dump && !save()) { + return ProcessBuilder.Redirect.INHERIT; + } else { + return ProcessBuilder.Redirect.PIPE; + } + } + + CachingPrintStream.Builder buildCachingPrintStream(PrintStream dumpStream, Charset charset) { + Objects.requireNonNull(dumpStream); + final var builder = CachingPrintStream.build(charset).save(save()).discard(discard()); + if (dump()) { + builder.dumpStream(dumpStream); + } + return builder; + } + + private boolean dump; + private boolean discard; + private Optional save = Optional.empty(); + } + + private record CommandOutput(Optional> content, int stdoutContentSize, boolean interleaved) { + + CommandOutput { + Objects.requireNonNull(content); + if (interleaved) { + stdoutContentSize = content.map(Content::size).orElse(-1); + } + } + + CommandOutput() { + this(Optional.empty(), 0, false); + } + + Optional combined() { + return content.map(Content::data); + } + + /** + * Returns non-empty {@code Optional} if stdout is available and stdout and stderr are not interleaved. + * @return stdout if it can be extracted from the combined output + */ + Optional stdout() { + if (withoutExtractableStdout()) { + return Optional.empty(); + } + + final var theContent = content.orElseThrow(); + if (stdoutContentSize == theContent.size()) { + return combined(); + } else { + return Optional.of(theContent.slice(0, Integer.min(stdoutContentSize, theContent.size())).data()); + } + } + + /** + * Returns non-empty {@code Optional} if stderr is available and stdout and stderr are not interleaved. + * @return stderr if it can be extracted from the combined output + */ + Optional stderr() { + if (withoutExtractableStderr()) { + return Optional.empty(); + } else if (stdoutContentSize <= 0) { + return combined(); + } else { + final var theContent = content.orElseThrow(); + return Optional.of(theContent.slice(stdoutContentSize, theContent.size()).data()); + } + } + + @SuppressWarnings("unchecked") + static CommandOutput empty() { + return (CommandOutput)EMPTY; + } + + private boolean withoutExtractableStdout() { + return interleaved || content.isEmpty() || stdoutContentSize < 0; + } + + private boolean withoutExtractableStderr() { + return interleaved || content.isEmpty() || stdoutContentSize > content.get().size(); + } + + private static final CommandOutput EMPTY = new CommandOutput<>(); + } + + private record ToolProviderExecutable(ToolProvider tp, List args, CommandOutputControl coc) implements Executable { + + ToolProviderExecutable { + Objects.requireNonNull(tp); + Objects.requireNonNull(args); + Objects.requireNonNull(coc); + } + + @Override + public Result execute() throws IOException, InterruptedException { + return coc.execute(tp, -1, args.toArray(String[]::new)); + } + + @Override + public Result execute(long timeout, TimeUnit unit) throws IOException, InterruptedException { + return coc.execute(tp, unit.toMillis(timeout), args.toArray(String[]::new)); + } + + @Override + public ExecutableAttributes attributes() { + return new ToolProviderAttributes(tp.name(), args); + } + } + + private record ProcessExecutable(ProcessBuilder pb, CommandOutputControl coc) implements Executable { + + ProcessExecutable { + Objects.requireNonNull(pb); + Objects.requireNonNull(coc); + } + + @Override + public Result execute() throws IOException, InterruptedException { + return coc.execute(pb, -1L); + } + + @Override + public Result execute(long timeout, TimeUnit unit) throws IOException, InterruptedException { + return coc.execute(pb, unit.toMillis(timeout)); + } + + @Override + public ExecutableAttributes attributes() { + return new ProcessAttributes(Optional.empty(), pb.command()); + } + } + + private static Optional getPID(Process p) { + try { + return Optional.of(p.pid()); + } catch (UnsupportedOperationException ex) { + return Optional.empty(); + } + } + + private static void suppressIOException(ThrowingRunnable r) { + try { + r.run(); + } catch (IOException ex) {} + } + + private static void suppressIOException(ThrowingConsumer c, T value) { + suppressIOException(() -> { + c.accept(value); + }); + } + + private int flags; + private final OutputStreamsControl outputStreamsControl; + private PrintStream dumpStdout; + private PrintStream dumpStderr; + private Charset charset; + private Consumer processListener; + + // Executor to run subprocess output stream gobblers. + // Output stream gobblers should start fetching output streams ASAP after the process starts. + // No pooling, no waiting. + // CompletableFuture#runAsync() method starts an output stream gobbler. + // If used with the default executor, it is known to make WiX3 light.exe create + // a locked msi file when multiple jpackage tool providers are executed asynchronously. + // The AsyncTest fails with cryptic java.nio.file.FileSystemException error: + // jtreg_open_test_jdk_tools_jpackage_share_AsyncTest_java\\tmp\\jdk.jpackage8108811639097525318\\msi\\Foo-1.0.msi: The process cannot access the file because it is being used by another process. + // The remedy for the problem is to use non-pooling executor to run subprocess output stream gobblers. + private final java.util.concurrent.Executor gobblerExecutor = Executors.newVirtualThreadPerTaskExecutor(); + + private enum OutputControlOption { + SAVE_ALL, SAVE_FIRST_LINE, DUMP + } + + private enum Flag { + DUMP (0x01), + REDIRECT_STDERR (0x02), + BINARY_OUTPUT (0x04), + STORE_OUTPUT_IN_FILES (0x08), + DISCARD_STDOUT (0x10), + DISCARD_STDERR (0x20), + ; + + Flag(int value) { + this.value = value; + } + + int set(int flags, boolean set) { + if (set) { + return flags | value; + } else { + return flags & ~value; + } + } + + boolean isSet(int flags) { + return (flags & value) != 0; + } + + private final int value; + } +} diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Enquoter.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/Enquoter.java similarity index 77% rename from src/jdk.jpackage/share/classes/jdk/jpackage/internal/Enquoter.java rename to src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/Enquoter.java index 51f97ad2cd6..d9ededcbadb 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Enquoter.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/Enquoter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2022, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -22,7 +22,7 @@ * or visit www.oracle.com if you need additional information or have any * questions. */ -package jdk.jpackage.internal; +package jdk.jpackage.internal.util; import java.util.Optional; import java.util.function.BiConsumer; @@ -32,39 +32,43 @@ import java.util.regex.Pattern; /** * Add quotes to the given string in a configurable way. */ -final class Enquoter { +public final class Enquoter { private Enquoter() { setQuoteChar('"'); } - static Enquoter forPropertyValues() { + public static Enquoter identity() { + return new Enquoter(); + } + + public static Enquoter forPropertyValues() { return new Enquoter() .setEnquotePredicate(QUOTE_IF_WHITESPACES) .setEscaper(PREPEND_BACKSLASH); } - static Enquoter forShellLiterals() { + public static Enquoter forShellLiterals() { return forShellLiterals('\''); } - static Enquoter forShellLiterals(char quoteChar) { + public static Enquoter forShellLiterals(char quoteChar) { return new Enquoter() .setQuoteChar(quoteChar) .setEnquotePredicate(x -> true) .setEscaper(PREPEND_BACKSLASH); } - String applyTo(String v) { + public String applyTo(String v) { if (!needQuotes.test(v)) { return v; } else { var buf = new StringBuilder(); buf.appendCodePoint(beginQuoteChr); - Optional.of(escaper).ifPresentOrElse(op -> { + Optional.ofNullable(escaper).ifPresentOrElse(op -> { v.codePoints().forEachOrdered(chr -> { if (chr == beginQuoteChr || chr == endQuoteChr) { - escaper.accept(chr, buf); + op.accept(chr, buf); } else { buf.appendCodePoint(chr); } @@ -77,28 +81,23 @@ final class Enquoter { } } - Enquoter setQuoteChar(char chr) { + public Enquoter setQuoteChar(char chr) { beginQuoteChr = chr; endQuoteChr = chr; return this; } - Enquoter setEscaper(BiConsumer v) { + public Enquoter setEscaper(BiConsumer v) { escaper = v; return this; } - Enquoter setEnquotePredicate(Predicate v) { + public Enquoter setEnquotePredicate(Predicate v) { needQuotes = v; return this; } - private int beginQuoteChr; - private int endQuoteChr; - private BiConsumer escaper; - private Predicate needQuotes = str -> false; - - private static final Predicate QUOTE_IF_WHITESPACES = new Predicate() { + public static final Predicate QUOTE_IF_WHITESPACES = new Predicate() { @Override public boolean test(String t) { return pattern.matcher(t).find(); @@ -106,8 +105,13 @@ final class Enquoter { private final Pattern pattern = Pattern.compile("\\s"); }; - private static final BiConsumer PREPEND_BACKSLASH = (chr, buf) -> { + public static final BiConsumer PREPEND_BACKSLASH = (chr, buf) -> { buf.append('\\'); buf.appendCodePoint(chr); }; + + private int beginQuoteChr; + private int endQuoteChr; + private BiConsumer escaper; + private Predicate needQuotes = str -> false; } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/RetryExecutor.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/RetryExecutor.java new file mode 100644 index 00000000000..c67373e8233 --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/RetryExecutor.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2020, 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal.util; + +import static jdk.jpackage.internal.util.function.ThrowingConsumer.toConsumer; + +import java.time.Duration; +import java.util.Iterator; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; +import jdk.jpackage.internal.util.function.ExceptionBox; +import jdk.jpackage.internal.util.function.ThrowingFunction; +import jdk.jpackage.internal.util.function.ThrowingSupplier; + +public class RetryExecutor { + + public RetryExecutor(Class exceptionType) { + this.exceptionType = Objects.requireNonNull(exceptionType); + setMaxAttemptsCount(5); + setAttemptTimeout(2, TimeUnit.SECONDS); + } + + final public Class exceptionType() { + return exceptionType; + } + + public RetryExecutor setExecutable(ThrowingFunction>, T, E> v) { + executable = v; + return this; + } + + final public RetryExecutor setExecutable(ThrowingSupplier v) { + if (v != null) { + setExecutable(_ -> { + return v.get(); + }); + } else { + executable = null; + } + return this; + } + + public RetryExecutor setMaxAttemptsCount(int v) { + attempts = v; + return this; + } + + final public RetryExecutor setAttemptTimeout(long v, TimeUnit unit) { + return setAttemptTimeout(Duration.of(v, unit.toChronoUnit())); + } + + public RetryExecutor setAttemptTimeout(Duration v) { + timeout = v; + return this; + } + + public RetryExecutor setExceptionMapper(Function v) { + toUnchecked = v; + return this; + } + + public RetryExecutor setSleepFunction(Consumer v) { + sleepFunction = v; + return this; + } + + final public RetryExecutor mutate(Consumer> mutator) { + mutator.accept(this); + return this; + } + + public T execute() throws E { + var curExecutable = executable(); + T result = null; + var attemptIter = new DefaultContext(); + while (attemptIter.hasNext()) { + attemptIter.next(); + try { + result = curExecutable.apply(attemptIter); + break; + } catch (Exception ex) { + if (!exceptionType.isInstance(ex)) { + throw ExceptionBox.toUnchecked(ex); + } else if (attemptIter.isLastAttempt()) { + // No more attempts left. This is fatal. + throw exceptionType.cast(ex); + } else { + curExecutable = executable(); + } + } + + sleep(); + } + + return result; + } + + final public T executeUnchecked() { + try { + return execute(); + } catch (Error | RuntimeException t) { + throw t; + } catch (Exception ex) { + if (exceptionType.isInstance(ex)) { + throw Optional.ofNullable(toUnchecked).orElse(ExceptionBox::toUnchecked).apply(exceptionType.cast(ex)); + } else { + // Unreachable unless it is a direct subclass of Throwable, + // which is not Error or Exception which should not happen. + throw ExceptionBox.reachedUnreachable(); + } + } + } + + public interface Context { + boolean isLastAttempt(); + int attempt(); + T executor(); + } + + private final class DefaultContext implements Context>, Iterator { + + @Override + public boolean isLastAttempt() { + return !hasNext(); + } + + @Override + public int attempt() { + return attempt; + } + + @Override + public boolean hasNext() { + return (attempts - attempt) > 1; + } + + @Override + public Void next() { + attempt++; + return null; + } + + @Override + public RetryExecutor executor() { + return RetryExecutor.this; + } + + private int attempt = -1; + } + + private ThrowingFunction>, T, E> executable() { + return Optional.ofNullable(executable).orElseThrow(() -> { + return new IllegalStateException("No executable"); + }); + } + + private void sleep() { + Optional.ofNullable(timeout).ifPresent(Optional.ofNullable(sleepFunction).orElseGet(() -> { + return toConsumer(Thread::sleep); + })); + } + + private final Class exceptionType; + private ThrowingFunction>, T, E> executable; + private int attempts; + private Duration timeout; + private Function toUnchecked; + private Consumer sleepFunction; +} diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/TeeOutputStream.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/TeeOutputStream.java new file mode 100644 index 00000000000..db098eb2b48 --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/TeeOutputStream.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal.util; + +import java.io.Closeable; +import java.io.Flushable; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Objects; +import jdk.jpackage.internal.util.function.ThrowingConsumer; + +public final class TeeOutputStream extends OutputStream { + + public TeeOutputStream(Iterable items) { + items.forEach(Objects::requireNonNull); + this.items = items; + } + + @Override + public void write(int b) throws IOException { + for (final var item : items) { + item.write(b); + } + } + + @Override + public void write(byte[] b) throws IOException { + for (final var item : items) { + item.write(b); + } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + for (final var item : items) { + item.write(b, off, len); + } + } + + @Override + public void flush() throws IOException { + forEach(Flushable::flush); + } + + @Override + public void close() throws IOException { + forEach(Closeable::close); + } + + private void forEach(ThrowingConsumer c) throws IOException { + IOException firstEx = null; + for (final var item : items) { + try { + c.accept(item); + } catch (IOException e) { + if (firstEx == null) { + firstEx = e; + } + } + } + if (firstEx != null) { + throw firstEx; + } + } + + private final Iterable items; +} diff --git a/src/jdk.jpackage/unix/classes/jdk/jpackage/internal/UnixLaunchersAsServices.java b/src/jdk.jpackage/unix/classes/jdk/jpackage/internal/UnixLaunchersAsServices.java index e1e02ba7850..b196a5805a0 100644 --- a/src/jdk.jpackage/unix/classes/jdk/jpackage/internal/UnixLaunchersAsServices.java +++ b/src/jdk.jpackage/unix/classes/jdk/jpackage/internal/UnixLaunchersAsServices.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2022, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -24,8 +24,6 @@ */ package jdk.jpackage.internal; -import jdk.jpackage.internal.model.Launcher; -import jdk.jpackage.internal.model.Application; import java.io.IOException; import java.nio.file.Path; import java.util.Collections; @@ -36,6 +34,9 @@ import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; +import jdk.jpackage.internal.model.Application; +import jdk.jpackage.internal.model.Launcher; +import jdk.jpackage.internal.util.Enquoter; /** * Helper to install launchers as services for Unix installers. diff --git a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinBundlingEnvironment.java b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinBundlingEnvironment.java index dc9891f154a..de52a222d7d 100644 --- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinBundlingEnvironment.java +++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinBundlingEnvironment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -31,16 +31,17 @@ import static jdk.jpackage.internal.cli.StandardBundlingOperation.CREATE_WIN_EXE import static jdk.jpackage.internal.cli.StandardBundlingOperation.CREATE_WIN_MSI; import jdk.jpackage.internal.cli.Options; -import jdk.jpackage.internal.util.Result; public class WinBundlingEnvironment extends DefaultBundlingEnvironment { public WinBundlingEnvironment() { - super(build() - .defaultOperation(CREATE_WIN_EXE) - .bundler(CREATE_WIN_APP_IMAGE, WinBundlingEnvironment::createAppImage) - .bundler(CREATE_WIN_EXE, LazyLoad::sysEnv, WinBundlingEnvironment::createExePackage) - .bundler(CREATE_WIN_MSI, LazyLoad::sysEnv, WinBundlingEnvironment::createMsiPackage)); + super(build().mutate(builder -> { + var sysEnv = runOnce(WinSystemEnvironment::create); + + builder + .bundler(CREATE_WIN_EXE, sysEnv, WinBundlingEnvironment::createExePackage) + .bundler(CREATE_WIN_MSI, sysEnv, WinBundlingEnvironment::createMsiPackage); + }).defaultOperation(CREATE_WIN_EXE).bundler(CREATE_WIN_APP_IMAGE, WinBundlingEnvironment::createAppImage)); } private static void createMsiPackage(Options options, WinSystemEnvironment sysEnv) { @@ -98,12 +99,4 @@ public class WinBundlingEnvironment extends DefaultBundlingEnvironment { } } - private static final class LazyLoad { - - static Result sysEnv() { - return SYS_ENV; - } - - private static final Result SYS_ENV = WinSystemEnvironment.create(); - } } diff --git a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WixTool.java b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WixTool.java index e2ed175fbf3..c4f8610312a 100644 --- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WixTool.java +++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WixTool.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -233,10 +233,10 @@ public enum WixTool { // Detect FIPS mode var fips = false; try { - final var exec = Executor.of(toolPath.toString(), "-?").setQuiet(true).saveOutput(true); - final var exitCode = exec.execute(); + final var result = Executor.of(toolPath.toString(), "-?").setQuiet(true).saveOutput(true).execute(); + final var exitCode = result.getExitCode(); if (exitCode != 0 /* 308 */) { - final var output = exec.getOutput(); + final var output = result.getOutput(); if (!output.isEmpty() && output.get(0).contains("error CNDL0308")) { fips = true; } diff --git a/test/jdk/tools/jpackage/helpers-test/jdk/jpackage/test/ExecutorTest.java b/test/jdk/tools/jpackage/helpers-test/jdk/jpackage/test/ExecutorTest.java deleted file mode 100644 index 2b075e0f13c..00000000000 --- a/test/jdk/tools/jpackage/helpers-test/jdk/jpackage/test/ExecutorTest.java +++ /dev/null @@ -1,401 +0,0 @@ -/* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA - * or visit www.oracle.com if you need additional information or have any - * questions. - */ -package jdk.jpackage.test; - -import static java.util.stream.Collectors.joining; -import static java.util.stream.Collectors.toSet; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.PrintStream; -import java.io.PrintWriter; -import java.io.UncheckedIOException; -import java.nio.charset.Charset; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.function.Consumer; -import java.util.spi.ToolProvider; -import java.util.stream.Stream; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -public class ExecutorTest extends JUnitAdapter { - - private record Command(List stdout, List stderr) { - Command { - stdout.forEach(Objects::requireNonNull); - stderr.forEach(Objects::requireNonNull); - } - - List asExecutable() { - final List commandline = new ArrayList<>(); - if (TKit.isWindows()) { - commandline.addAll(List.of("cmd", "/C")); - } else { - commandline.addAll(List.of("sh", "-c")); - } - commandline.add(Stream.concat(createEchoCommands(stdout), - createEchoCommands(stderr).map(v -> v + ">&2")).collect(joining(" && "))); - return commandline; - } - - private static Stream createEchoCommands(List lines) { - return lines.stream().map(line -> { - if (TKit.isWindows()) { - return "(echo " + line + ")"; - } else { - return "echo " + line; - } - }); - } - - ToolProvider asToolProvider() { - return new ToolProvider() { - - @Override - public int run(PrintWriter out, PrintWriter err, String... args) { - stdout.forEach(out::println); - stderr.forEach(err::println); - return 0; - } - - @Override - public String name() { - return "test"; - } - }; - } - } - - private enum OutputData { - EMPTY(List.of()), - ONE_LINE(List.of("Jupiter")), - MANY(List.of("Uranus", "Saturn", "Earth")); - - OutputData(List data) { - data.forEach(Objects::requireNonNull); - this.data = data; - } - - final List data; - } - - private record CommandSpec(OutputData stdout, OutputData stderr) { - CommandSpec { - Objects.requireNonNull(stdout); - Objects.requireNonNull(stderr); - } - - Command command() { - return new Command(stdout.data.stream().map(line -> { - return "stdout." + line; - }).toList(), stderr.data.stream().map(line -> { - return "stderr." + line; - }).toList()); - } - } - - public enum OutputControl { - DUMP(Executor::dumpOutput), - SAVE_ALL(Executor::saveOutput), - SAVE_FIRST_LINE(Executor::saveFirstLineOfOutput), - DISCARD_STDOUT(Executor::discardStdout), - DISCARD_STDERR(Executor::discardStderr), - ; - - OutputControl(Consumer configureExector) { - this.configureExector = Objects.requireNonNull(configureExector); - } - - Executor applyTo(Executor exec) { - configureExector.accept(exec); - return exec; - } - - static List> variants() { - final List> variants = new ArrayList<>(); - for (final var withDump : BOOLEAN_VALUES) { - variants.addAll(Stream.of( - Set.of(), - Set.of(SAVE_ALL), - Set.of(SAVE_FIRST_LINE), - Set.of(DISCARD_STDOUT), - Set.of(DISCARD_STDERR), - Set.of(SAVE_ALL, DISCARD_STDOUT), - Set.of(SAVE_FIRST_LINE, DISCARD_STDOUT), - Set.of(SAVE_ALL, DISCARD_STDERR), - Set.of(SAVE_FIRST_LINE, DISCARD_STDERR), - Set.of(SAVE_ALL, DISCARD_STDOUT, DISCARD_STDERR), - Set.of(SAVE_FIRST_LINE, DISCARD_STDOUT, DISCARD_STDERR) - ).map(v -> { - if (withDump) { - return Stream.concat(Stream.of(DUMP), v.stream()).collect(toSet()); - } else { - return v; - } - }).toList()); - } - return variants.stream().map(options -> { - return options.stream().filter(o -> { - return o.configureExector != NOP; - }).collect(toSet()); - }).distinct().toList(); - } - - private final Consumer configureExector; - - static final Set SAVE = Set.of(SAVE_ALL, SAVE_FIRST_LINE); - } - - public record OutputTestSpec(boolean toolProvider, Set outputControl, CommandSpec commandSpec) { - public OutputTestSpec { - outputControl.forEach(Objects::requireNonNull); - if (outputControl.containsAll(OutputControl.SAVE)) { - throw new IllegalArgumentException(); - } - Objects.requireNonNull(commandSpec); - } - - @Override - public String toString() { - final List tokens = new ArrayList<>(); - - if (toolProvider) { - tokens.add("tool-provider"); - } - - tokens.add("output=" + format(outputControl)); - tokens.add("command=" + commandSpec); - - return String.join(",", tokens.toArray(String[]::new)); - } - - void test() { - final var command = commandSpec.command(); - final var commandWithDiscardedStreams = discardStreams(command); - - final Executor.Result[] result = new Executor.Result[1]; - final var outputCapture = OutputCapture.captureOutput(() -> { - result[0] = createExecutor(command).executeWithoutExitCodeCheck(); - }); - - assertEquals(0, result[0].getExitCode()); - - // If we dump the subprocesses's output, and the command produced both STDOUT and STDERR, - // then the captured STDOUT may contain interleaved command's STDOUT and STDERR, - // not in sequential order (STDOUT followed by STDERR). - // In this case don't check the contents of the captured command's STDOUT. - if (toolProvider || outputCapture.outLines().isEmpty() || (command.stdout().isEmpty() || command.stderr().isEmpty())) { - assertEquals(expectedCapturedSystemOut(commandWithDiscardedStreams), outputCapture.outLines()); - } - assertEquals(expectedCapturedSystemErr(commandWithDiscardedStreams), outputCapture.errLines()); - - assertEquals(expectedResultStdout(commandWithDiscardedStreams), result[0].stdout().getOutput()); - assertEquals(expectedResultStderr(commandWithDiscardedStreams), result[0].stderr().getOutput()); - - if (!saveOutput()) { - assertNull(result[0].getOutput()); - } else { - assertNotNull(result[0].getOutput()); - final var allExpectedOutput = expectedCommandOutput(command); - assertEquals(allExpectedOutput.isEmpty(), result[0].getOutput().isEmpty()); - if (!allExpectedOutput.isEmpty()) { - if (outputControl.contains(OutputControl.SAVE_ALL)) { - assertEquals(allExpectedOutput, result[0].getOutput()); - } else if (outputControl.contains(OutputControl.SAVE_FIRST_LINE)) { - assertEquals(1, result[0].getOutput().size()); - assertEquals(allExpectedOutput.getFirst(), result[0].getFirstLineOfOutput()); - } else { - throw new UnsupportedOperationException(); - } - } - } - } - - private boolean dumpOutput() { - return outputControl.contains(OutputControl.DUMP); - } - - private boolean saveOutput() { - return !Collections.disjoint(outputControl, OutputControl.SAVE); - } - - private boolean discardStdout() { - return outputControl.contains(OutputControl.DISCARD_STDOUT); - } - - private boolean discardStderr() { - return outputControl.contains(OutputControl.DISCARD_STDERR); - } - - private static String format(Set outputControl) { - return outputControl.stream().map(OutputControl::name).sorted().collect(joining("+")); - } - - private List expectedCapturedSystemOut(Command command) { - if (!dumpOutput() || (!toolProvider && !saveOutput())) { - return List.of(); - } else if(saveOutput()) { - return Stream.concat(command.stdout().stream(), command.stderr().stream()).toList(); - } else { - return command.stdout(); - } - } - - private List expectedCapturedSystemErr(Command command) { - if (!dumpOutput() || (!toolProvider && !saveOutput())) { - return List.of(); - } else if(saveOutput()) { - return List.of(); - } else { - return command.stderr(); - } - } - - private List expectedResultStdout(Command command) { - return expectedResultStream(command.stdout()); - } - - private List expectedResultStderr(Command command) { - if (outputControl.contains(OutputControl.SAVE_FIRST_LINE) && !command.stdout().isEmpty()) { - return List.of(); - } - return expectedResultStream(command.stderr()); - } - - private List expectedResultStream(List commandOutput) { - Objects.requireNonNull(commandOutput); - if (outputControl.contains(OutputControl.SAVE_ALL)) { - return commandOutput; - } else if (outputControl.contains(OutputControl.SAVE_FIRST_LINE)) { - return commandOutput.stream().findFirst().map(List::of).orElseGet(List::of); - } else { - return null; - } - } - - private Command discardStreams(Command command) { - return new Command(discardStdout() ? List.of() : command.stdout(), discardStderr() ? List.of() : command.stderr()); - } - - private record OutputCapture(byte[] out, byte[] err, Charset outCharset, Charset errCharset) { - OutputCapture { - Objects.requireNonNull(out); - Objects.requireNonNull(err); - Objects.requireNonNull(outCharset); - Objects.requireNonNull(errCharset); - } - - List outLines() { - return toLines(out, outCharset); - } - - List errLines() { - return toLines(err, errCharset); - } - - private static List toLines(byte[] buf, Charset charset) { - try (var reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf), charset))) { - return reader.lines().filter(line -> { - return !line.contains("TRACE"); - }).toList(); - } catch (IOException ex) { - throw new UncheckedIOException(ex); - } - } - - static OutputCapture captureOutput(Runnable runnable) { - final var captureOut = new ByteArrayOutputStream(); - final var captureErr = new ByteArrayOutputStream(); - - final var out = System.out; - final var err = System.err; - try { - final var outCharset = System.out.charset(); - final var errCharset = System.err.charset(); - System.setOut(new PrintStream(captureOut, true, outCharset)); - System.setErr(new PrintStream(captureErr, true, errCharset)); - runnable.run(); - return new OutputCapture(captureOut.toByteArray(), captureErr.toByteArray(), outCharset, errCharset); - } finally { - try { - System.setOut(out); - } finally { - System.setErr(err); - } - } - } - } - - private List expectedCommandOutput(Command command) { - command = discardStreams(command); - return Stream.of(command.stdout(), command.stderr()).flatMap(List::stream).toList(); - } - - private Executor createExecutor(Command command) { - final Executor exec; - if (toolProvider) { - exec = Executor.of(command.asToolProvider()); - } else { - exec = Executor.of(command.asExecutable()); - } - - outputControl.forEach(control -> control.applyTo(exec)); - - return exec; - } - } - - @ParameterizedTest - @MethodSource - public void testSavedOutput(OutputTestSpec spec) { - spec.test(); - } - - public static List testSavedOutput() { - List testCases = new ArrayList<>(); - for (final var toolProvider : BOOLEAN_VALUES) { - for (final var outputControl : OutputControl.variants()) { - for (final var stdoutContent : List.of(OutputData.values())) { - for (final var stderrContent : List.of(OutputData.values())) { - final var commandSpec = new CommandSpec(stdoutContent, stderrContent); - testCases.add(new OutputTestSpec(toolProvider, outputControl, commandSpec)); - } - } - } - } - return testCases; - } - - private static final List BOOLEAN_VALUES = List.of(Boolean.TRUE, Boolean.FALSE); - private static final Consumer NOP = exec -> {}; -} diff --git a/test/jdk/tools/jpackage/helpers-test/jdk/jpackage/test/PackageTestTest.java b/test/jdk/tools/jpackage/helpers-test/jdk/jpackage/test/PackageTestTest.java index 16909d0eb40..3e169b9e184 100644 --- a/test/jdk/tools/jpackage/helpers-test/jdk/jpackage/test/PackageTestTest.java +++ b/test/jdk/tools/jpackage/helpers-test/jdk/jpackage/test/PackageTestTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -213,7 +213,7 @@ public class PackageTestTest extends JUnitAdapter { @Override public void accept(JPackageCommand cmd, Executor.Result result) { tick(); - jpackageExitCode = result.exitCode(); + jpackageExitCode = result.getExitCode(); } @Override @@ -371,8 +371,7 @@ public class PackageTestTest extends JUnitAdapter { } catch (IOException ex) { throw new UncheckedIOException(ex); } - return new Executor.Result(actualJPackageExitCode, - this::getPrintableCommandLine).assertExitCodeIs(expectedExitCode); + return new Executor.Result(actualJPackageExitCode).assertExitCodeIs(expectedExitCode); } }; }).setExpectedExitCode(expectedJPackageExitCode) diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/Executor.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/Executor.java index ef118e525c5..5d3033e2e8c 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/Executor.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/Executor.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -22,18 +22,9 @@ */ package jdk.jpackage.test; -import static java.util.stream.Collectors.joining; - -import java.io.BufferedReader; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.PrintStream; -import java.io.StringReader; import java.io.UncheckedIOException; -import java.io.Writer; +import java.nio.charset.Charset; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; @@ -43,15 +34,17 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; import java.util.function.Supplier; -import java.util.regex.Pattern; import java.util.spi.ToolProvider; -import java.util.stream.Collectors; +import java.util.stream.IntStream; import java.util.stream.Stream; -import jdk.jpackage.internal.util.function.ThrowingSupplier; +import jdk.jpackage.internal.util.CommandLineFormat; +import jdk.jpackage.internal.util.CommandOutputControl; +import jdk.jpackage.internal.util.CommandOutputControl.UnexpectedExitCodeException; +import jdk.jpackage.internal.util.RetryExecutor; import jdk.jpackage.internal.util.function.ExceptionBox; +import jdk.jpackage.internal.util.function.ThrowingSupplier; public final class Executor extends CommandArguments { @@ -69,8 +62,6 @@ public final class Executor extends CommandArguments { } public Executor() { - outputStreamsControl = new OutputStreamsControl(); - winEnglishOutput = false; } public Executor setExecutable(String v) { @@ -136,62 +127,31 @@ public final class Executor extends CommandArguments { return this; } - /** - * Configures this instance to save all stdout and stderr streams from the to be - * executed command. - *

- * This function is mutually exclusive with {@link #saveFirstLineOfOutput()}. - * - * @return this - */ public Executor saveOutput() { return saveOutput(true); } - /** - * Configures if all stdout and stderr streams from the to be executed command - * should be saved. - *

- * If v is true, the function call is equivalent to - * {@link #saveOutput()} call. If v is false, command - * output will not be saved. - * - * @parameter v if both stdout and stderr streams should be saved - * - * @return this - */ public Executor saveOutput(boolean v) { - return setOutputControl(v, OutputControlOption.SAVE_ALL); + commandOutputControl.saveOutput(v); + return this; } - /** - * Configures this instance to save the first line of a stream merged from - * stdout and stderr streams from the to be executed command. - *

- * This function is mutually exclusive with {@link #saveOutput()}. - * - * @return this - */ public Executor saveFirstLineOfOutput() { - return setOutputControl(true, OutputControlOption.SAVE_FIRST_LINE); + commandOutputControl.saveFirstLineOfOutput(); + return this; } - /** - * Configures this instance to dump both stdout and stderr streams from the to - * be executed command into {@link System.out}. - * - * @return this - */ public Executor dumpOutput() { return dumpOutput(true); } public Executor dumpOutput(boolean v) { - return setOutputControl(v, OutputControlOption.DUMP); + commandOutputControl.dumpOutput(v); + return this; } public Executor discardStdout(boolean v) { - outputStreamsControl.stdout().discard(v); + commandOutputControl.discardStdout(v); return this; } @@ -200,7 +160,7 @@ public final class Executor extends CommandArguments { } public Executor discardStderr(boolean v) { - outputStreamsControl.stderr().discard(v); + commandOutputControl.discardStderr(v); return this; } @@ -208,45 +168,126 @@ public final class Executor extends CommandArguments { return discardStderr(true); } - public interface Output { - public List getOutput(); - - public default String getFirstLineOfOutput() { - return findFirstLineOfOutput().orElseThrow(); - } - - public default Optional findFirstLineOfOutput() { - return getOutput().stream().findFirst(); - } + public Executor binaryOutput(boolean v) { + commandOutputControl.binaryOutput(v); + return this; } - public record Result(int exitCode, CommandOutput output, Supplier cmdline) implements Output { + public Executor binaryOutput() { + return binaryOutput(true); + } + + public Executor charset(Charset v) { + commandOutputControl.charset(v); + return this; + } + + public Charset charset() { + return commandOutputControl.charset(); + } + + Executor storeOutputInFiles(boolean v) { + commandOutputControl.storeOutputInFiles(v); + return this; + } + + Executor storeOutputInFiles() { + return storeOutputInFiles(true); + } + + public record Result(CommandOutputControl.Result base) { public Result { - Objects.requireNonNull(output); - Objects.requireNonNull(cmdline); + Objects.requireNonNull(base); } - public Result(int exitCode, Supplier cmdline) { - this(exitCode, CommandOutput.EMPTY, cmdline); + public Result(int exitCode) { + this(new CommandOutputControl.Result(exitCode)); } - @Override public List getOutput() { - return output.lines().orElse(null); + return base.content(); } - public Output stdout() { - return createView(output.stdoutLines()); + public String getFirstLineOfOutput() { + return getOutput().getFirst(); } - public Output stderr() { - return createView(output.stderrLines()); + public List stdout() { + return base.stdout(); } - public Result assertExitCodeIs(int expectedExitCode) { - TKit.assertEquals(expectedExitCode, exitCode, String.format( - "Check command %s exited with %d code", - cmdline.get(), expectedExitCode)); + public List stderr() { + return base.stderr(); + } + + public Optional> findContent() { + return base.findContent(); + } + + public Optional> findStdout() { + return base.findStdout(); + } + + public Optional> findStderr() { + return base.findStderr(); + } + + public byte[] byteContent() { + return base.byteContent(); + } + + public byte[] byteStdout() { + return base.byteStdout(); + } + + public byte[] byteStderr() { + return base.byteStderr(); + } + + public Optional findByteContent() { + return base.findByteContent(); + } + + public Optional findByteStdout() { + return base.findByteStdout(); + } + + public Optional findByteStderr() { + return base.findByteStderr(); + } + + public Result toCharacterResult(Charset charset, boolean keepByteContent) { + try { + return new Result(base.toCharacterResult(charset, keepByteContent)); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + public Result assertExitCodeIs(int main, int... other) { + if (other.length != 0) { + return assertExitCodeIs(IntStream.concat(IntStream.of(main), IntStream.of(other)).boxed().toList()); + } else { + return assertExitCodeIs(List.of(main)); + } + } + + private Result assertExitCodeIs(List expectedExitCodes) { + Objects.requireNonNull(expectedExitCodes); + switch (expectedExitCodes.size()) { + case 0 -> { + throw new IllegalArgumentException(); + } case 1 -> { + long expectedExitCode = expectedExitCodes.getFirst(); + TKit.assertEquals(expectedExitCode, getExitCode(), String.format( + "Check command %s exited with %d code", + base.execAttrs(), expectedExitCode)); + } default -> { + TKit.assertTrue(expectedExitCodes.contains(getExitCode()), String.format( + "Check command %s exited with one of %s codes", + base.execAttrs(), expectedExitCodes.stream().sorted().toList())); + } + } return this; } @@ -255,16 +296,11 @@ public final class Executor extends CommandArguments { } public int getExitCode() { - return exitCode; + return base.getExitCode(); } - private static Output createView(Optional> lines) { - return new Output() { - @Override - public List getOutput() { - return lines.orElse(null); - } - }; + public String getPrintableCommandLine() { + return base.execAttrs().toString(); } } @@ -292,8 +328,8 @@ public final class Executor extends CommandArguments { }).get(); } - public Result execute(int expectedCode) { - return executeWithoutExitCodeCheck().assertExitCodeIs(expectedCode); + Result execute(int mainExitCode, int... otherExitCodes) { + return executeWithoutExitCodeCheck().assertExitCodeIs(mainExitCode, otherExitCodes); } public Result execute() { @@ -301,28 +337,36 @@ public final class Executor extends CommandArguments { } public String executeAndGetFirstLineOfOutput() { - return saveFirstLineOfOutput().execute().getFirstLineOfOutput(); + return saveFirstLineOfOutput().execute().getOutput().getFirst(); } public List executeAndGetOutput() { return saveOutput().execute().getOutput(); } - private static class BadResultException extends RuntimeException { - BadResultException(Result v) { - value = v; + private static class FailedAttemptException extends Exception { + FailedAttemptException(Exception cause) { + super(Objects.requireNonNull(cause)); } - Result getValue() { - return value; - } - - private final transient Result value; private static final long serialVersionUID = 1L; } + public RetryExecutor retryUntilExitCodeIs( + int mainExpectedExitCode, int... otherExpectedExitCodes) { + return new RetryExecutor(UnexpectedExitCodeException.class).setExecutable(() -> { + var result = executeWithoutExitCodeCheck(); + result.base().expectExitCode(mainExpectedExitCode, otherExpectedExitCodes); + return result; + }).setExceptionMapper((UnexpectedExitCodeException ex) -> { + createResult(ex.getResult()).assertExitCodeIs(mainExpectedExitCode, otherExpectedExitCodes); + // Unreachable, because the above `Result.assertExitCodeIs(...)` must throw. + throw ExceptionBox.reachedUnreachable(); + }); + } + /** - * Executes the configured command {@code max} at most times and waits for + * Executes the configured command at most {@code max} times and waits for * {@code wait} seconds between each execution until the command exits with * {@code expectedCode} exit code. * @@ -332,17 +376,10 @@ public final class Executor extends CommandArguments { * command */ public Result executeAndRepeatUntilExitCode(int expectedExitCode, int max, int wait) { - try { - return tryRunMultipleTimes(() -> { - Result result = executeWithoutExitCodeCheck(); - if (result.getExitCode() != expectedExitCode) { - throw new BadResultException(result); - } - return result; - }, max, wait).assertExitCodeIs(expectedExitCode); - } catch (BadResultException ex) { - return ex.getValue().assertExitCodeIs(expectedExitCode); - } + return retryUntilExitCodeIs(expectedExitCode) + .setAttemptTimeout(wait, TimeUnit.SECONDS) + .setMaxAttemptsCount(max) + .executeUnchecked(); } /** @@ -359,26 +396,16 @@ public final class Executor extends CommandArguments { * @param wait number of seconds to wait between executions of the */ public static T tryRunMultipleTimes(Supplier task, int max, int wait) { - RuntimeException lastException = null; - int count = 0; - - do { + return new RetryExecutor(FailedAttemptException.class).setExecutable(() -> { try { return task.get(); } catch (RuntimeException ex) { - lastException = ex; + throw new FailedAttemptException(ex); } + }).setExceptionMapper((FailedAttemptException ex) -> { + return (RuntimeException)ex.getCause(); + }).setAttemptTimeout(wait, TimeUnit.SECONDS).setMaxAttemptsCount(max).executeUnchecked(); - try { - Thread.sleep(wait * 1000); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); - } - - count++; - } while (count < max); - - throw lastException; } public static void tryRunMultipleTimes(Runnable task, int max, int wait) { @@ -392,12 +419,6 @@ public final class Executor extends CommandArguments { return saveOutput().executeWithoutExitCodeCheck().getOutput(); } - private Executor setOutputControl(boolean set, OutputControlOption v) { - outputStreamsControl.stdout().set(set, v); - outputStreamsControl.stderr().set(set, v); - return this; - } - private Path executablePath() { if (directory == null || executable.isAbsolute() @@ -431,12 +452,8 @@ public final class Executor extends CommandArguments { builder.environment().put("TMP", winTmpDir); } - outputStreamsControl.applyTo(builder); - StringBuilder sb = new StringBuilder(getPrintableCommandLine()); - outputStreamsControl.describe().ifPresent(desc -> { - sb.append("; ").append(desc); - }); + sb.append("; ").append(commandOutputControl.description()); if (directory != null) { builder.directory(directory.toFile()); @@ -466,141 +483,31 @@ public final class Executor extends CommandArguments { }); } - trace("Execute " + sb.toString() + "..."); - Process process = builder.start(); - - var stdoutGobbler = CompletableFuture.>>supplyAsync(() -> { - try { - return processProcessStream(outputStreamsControl.stdout(), process.getInputStream()); - } catch (IOException ex) { - throw new UncheckedIOException(ex); - } - }); - - var stderrGobbler = CompletableFuture.>>supplyAsync(() -> { - try { - return processProcessStream(outputStreamsControl.stderr(), process.getErrorStream()); - } catch (IOException ex) { - throw new UncheckedIOException(ex); - } - }); - - final CommandOutput output; - - try { - output = combine(stdoutGobbler.join(), stderrGobbler.join()); - } catch (CompletionException ex) { - var cause = ex.getCause(); - switch (cause) { - case UncheckedIOException uioex -> { - throw uioex.getCause(); - } - default -> { - throw ExceptionBox.toUnchecked(ExceptionBox.unbox(cause)); - } - } - } - - final int exitCode = process.waitFor(); - trace("Done. Exit code: " + exitCode); - - return createResult(exitCode, output); + return execute(sb, commandOutputControl.createExecutable(builder)); } - private int runToolProvider(PrintStream out, PrintStream err) { + private Result runToolProvider() throws IOException, InterruptedException { final var sb = new StringBuilder(getPrintableCommandLine()); - outputStreamsControl.describe().ifPresent(desc -> { - sb.append("; ").append(desc); - }); - trace("Execute " + sb + "..."); - final int exitCode = toolProvider.run(out, err, args.toArray( - String[]::new)); - trace("Done. Exit code: " + exitCode); - return exitCode; + sb.append("; ").append(commandOutputControl.description()); + + return execute(sb, commandOutputControl.createExecutable(toolProvider, args.toArray(String[]::new))); } - private Result runToolProvider() throws IOException { - final var toolProviderStreamConfig = ToolProviderStreamConfig.create(outputStreamsControl); + private Result execute(StringBuilder traceMsg, CommandOutputControl.Executable exec) throws IOException, InterruptedException { + Objects.requireNonNull(traceMsg); - final var exitCode = runToolProvider(toolProviderStreamConfig); + trace("Execute " + traceMsg + "..."); - final var output = combine( - read(outputStreamsControl.stdout(), toolProviderStreamConfig.out()), - read(outputStreamsControl.stderr(), toolProviderStreamConfig.err())); - return createResult(exitCode, output); + var result = exec.execute(); + + trace("Done. Exit code: " + result.getExitCode()); + + return createResult(result); } - private int runToolProvider(ToolProviderStreamConfig cfg) throws IOException { - try { - return runToolProvider(cfg.out().ps(), cfg.err().ps()); - } finally { - cfg.out().ps().flush(); - cfg.err().ps().flush(); - } - } - - private static Optional> processProcessStream(OutputControl outputControl, InputStream in) throws IOException { - List outputLines = null; - try (final var bufReader = new BufferedReader(new InputStreamReader(in))) { - if (outputControl.dump() || outputControl.saveAll()) { - outputLines = bufReader.lines().toList(); - } else if (outputControl.saveFirstLine()) { - outputLines = Optional.ofNullable(bufReader.readLine()).map(List::of).orElseGet(List::of); - // Read all input, or the started process may exit with an error (cmd.exe does so). - bufReader.transferTo(Writer.nullWriter()); - } else { - // This should be empty input stream, fetch it anyway. - bufReader.transferTo(Writer.nullWriter()); - } - } finally { - if (outputControl.dump() && outputLines != null) { - outputLines.forEach(System.out::println); - if (outputControl.saveFirstLine()) { - outputLines = outputLines.stream().findFirst().map(List::of).orElseGet(List::of); - } - } - if (!outputControl.save()) { - outputLines = null; - } - } - return Optional.ofNullable(outputLines); - } - - private static Optional> read(OutputControl outputControl, CachingPrintStream cps) throws IOException { - final var bufferAsString = cps.bufferContents(); - try (final var bufReader = new BufferedReader(new StringReader(bufferAsString.orElse("")))) { - if (outputControl.saveFirstLine()) { - return Optional.of(bufReader.lines().findFirst().map(List::of).orElseGet(List::of)); - } else if (outputControl.saveAll()) { - return Optional.of(bufReader.lines().toList()); - } else if (bufferAsString.isPresent()) { - return Optional.of(List.of()); - } else { - return Optional.empty(); - } - } - } - - private CommandOutput combine(Optional> out, Optional> err) { - if (out.isEmpty() && err.isEmpty()) { - return new CommandOutput(); - } else if (out.isEmpty()) { - return new CommandOutput(err, -1); - } else if (err.isEmpty()) { - return new CommandOutput(out, Integer.MAX_VALUE); - } else { - final var combined = Stream.of(out, err).map(Optional::orElseThrow).flatMap(List::stream); - if (outputStreamsControl.stdout().saveFirstLine() && outputStreamsControl.stderr().saveFirstLine()) { - return new CommandOutput(Optional.of(combined.findFirst().map(List::of).orElseGet(List::of)), - Integer.min(1, out.orElseThrow().size())); - } else { - return new CommandOutput(Optional.of(combined.toList()), out.orElseThrow().size()); - } - } - } - - private Result createResult(int exitCode, CommandOutput output) { - return new Result(exitCode, output, this::getPrintableCommandLine); + private Result createResult(CommandOutputControl.Result baseResult) { + return new Result(baseResult.copyWithExecutableAttributes( + new ExecutableAttributes(baseResult.execAttrs(), getPrintableCommandLine()))); } public String getPrintableCommandLine() { @@ -618,359 +525,40 @@ public final class Executor extends CommandArguments { var cmdline = Stream.of(prefixCommandLineArgs(), List.of(exec), args).flatMap( List::stream).toList(); - return String.format(format, printCommandLine(cmdline), cmdline.size()); + return String.format(format, CommandLineFormat.DEFAULT.apply(cmdline), cmdline.size()); } - private static String printCommandLine(List cmdline) { - // Want command line printed in a way it can be easily copy/pasted - // to be executed manually - Pattern regex = Pattern.compile("\\s"); - return cmdline.stream().map( - v -> (v.isEmpty() || regex.matcher(v).find()) ? "\"" + v + "\"" : v).collect( - Collectors.joining(" ")); + private record ExecutableAttributes(CommandOutputControl.ExecutableAttributes base, String toStringValue) + implements CommandOutputControl.ExecutableAttributes { + + ExecutableAttributes { + Objects.requireNonNull(base); + if (toStringValue.isBlank()) { + throw new IllegalArgumentException(); + } + } + + @Override + public String toString() { + return toStringValue; + } + + @Override + public List commandLine() { + return base.commandLine(); + } } private static void trace(String msg) { TKit.trace(String.format("exec: %s", msg)); } - private static PrintStream nullPrintStream() { - return new PrintStream(OutputStream.nullOutputStream()); - } - - private record OutputStreamsControl(OutputControl stdout, OutputControl stderr) { - OutputStreamsControl { - Objects.requireNonNull(stdout); - Objects.requireNonNull(stderr); - } - - OutputStreamsControl() { - this(new OutputControl(), new OutputControl()); - } - - void applyTo(ProcessBuilder pb) { - pb.redirectOutput(stdout.asProcessBuilderRedirect()); - pb.redirectError(stderr.asProcessBuilderRedirect()); - } - - Optional describe() { - final List tokens = new ArrayList<>(); - if (stdout.save() || stderr.save()) { - streamsLabel("save ", true).ifPresent(tokens::add); - } - if (stdout.dump() || stderr.dump()) { - streamsLabel("inherit ", true).ifPresent(tokens::add); - } - streamsLabel("discard ", false).ifPresent(tokens::add); - if (tokens.isEmpty()) { - return Optional.empty(); - } else { - return Optional.of(String.join("; ", tokens)); - } - } - - Optional streamsLabel(String prefix, boolean negate) { - Objects.requireNonNull(prefix); - final var str = Stream.of(stdoutLabel(negate), stderrLabel(negate)) - .filter(Optional::isPresent) - .map(Optional::orElseThrow) - .collect(joining("+")); - if (str.isEmpty()) { - return Optional.empty(); - } else { - return Optional.of(prefix + str); - } - } - - private Optional stdoutLabel(boolean negate) { - if ((stdout.discard() && !negate) || (!stdout.discard() && negate)) { - return Optional.of("out"); - } else { - return Optional.empty(); - } - } - - private Optional stderrLabel(boolean negate) { - if ((stderr.discard() && !negate) || (!stderr.discard() && negate)) { - return Optional.of("err"); - } else { - return Optional.empty(); - } - } - } - - private record CachingPrintStream(PrintStream ps, Optional buf) { - CachingPrintStream { - Objects.requireNonNull(ps); - Objects.requireNonNull(buf); - } - - Optional bufferContents() { - return buf.map(ByteArrayOutputStream::toString); - } - - static Builder build() { - return new Builder(); - } - - static final class Builder { - - Builder save(boolean v) { - save = v; - return this; - } - - Builder discard(boolean v) { - discard = v; - return this; - } - - Builder dumpStream(PrintStream v) { - dumpStream = v; - return this; - } - - CachingPrintStream create() { - final Optional buf; - if (save && !discard) { - buf = Optional.of(new ByteArrayOutputStream()); - } else { - buf = Optional.empty(); - } - - final PrintStream ps; - if (buf.isPresent() && dumpStream != null) { - ps = new PrintStream(new TeeOutputStream(List.of(buf.orElseThrow(), dumpStream)), true, dumpStream.charset()); - } else if (!discard) { - ps = buf.map(PrintStream::new).or(() -> Optional.ofNullable(dumpStream)).orElseGet(Executor::nullPrintStream); - } else { - ps = nullPrintStream(); - } - - return new CachingPrintStream(ps, buf); - } - - private boolean save; - private boolean discard; - private PrintStream dumpStream; - } - } - - private record ToolProviderStreamConfig(CachingPrintStream out, CachingPrintStream err) { - ToolProviderStreamConfig { - Objects.requireNonNull(out); - Objects.requireNonNull(err); - } - - static ToolProviderStreamConfig create(OutputStreamsControl cfg) { - final var errCfgBuilder = cfg.stderr().buildCachingPrintStream(System.err); - if (cfg.stderr().dump() && cfg.stderr().save()) { - errCfgBuilder.dumpStream(System.out); - } - return new ToolProviderStreamConfig( - cfg.stdout().buildCachingPrintStream(System.out).create(), errCfgBuilder.create()); - } - } - - private static final class OutputControl { - - boolean save() { - return save.isPresent(); - } - - boolean saveAll() { - return save.orElse(null) == OutputControlOption.SAVE_ALL; - } - - boolean saveFirstLine() { - return save.orElse(null) == OutputControlOption.SAVE_FIRST_LINE; - } - - boolean discard() { - return discard || (!dump && save.isEmpty()); - } - - boolean dump() { - return !discard && dump; - } - - OutputControl dump(boolean v) { - this.dump = v; - return this; - } - - OutputControl discard(boolean v) { - this.discard = v; - return this; - } - - OutputControl saveAll(boolean v) { - if (v) { - save = Optional.of(OutputControlOption.SAVE_ALL); - } else { - save = Optional.empty(); - } - return this; - } - - OutputControl saveFirstLine(boolean v) { - if (v) { - save = Optional.of(OutputControlOption.SAVE_FIRST_LINE); - } else { - save = Optional.empty(); - } - return this; - } - - OutputControl set(boolean set, OutputControlOption v) { - switch (v) { - case DUMP -> dump(set); - case SAVE_ALL -> saveAll(set); - case SAVE_FIRST_LINE -> saveFirstLine(set); - } - return this; - } - - ProcessBuilder.Redirect asProcessBuilderRedirect() { - if (discard()) { - return ProcessBuilder.Redirect.DISCARD; - } else if (dump && !save()) { - return ProcessBuilder.Redirect.INHERIT; - } else { - return ProcessBuilder.Redirect.PIPE; - } - } - - CachingPrintStream.Builder buildCachingPrintStream(PrintStream dumpStream) { - Objects.requireNonNull(dumpStream); - final var builder = CachingPrintStream.build().save(save()).discard(discard()); - if (dump()) { - builder.dumpStream(dumpStream); - } - return builder; - } - - private boolean dump; - private boolean discard; - private Optional save = Optional.empty(); - } - - private static final class TeeOutputStream extends OutputStream { - - public TeeOutputStream(Iterable streams) { - streams.forEach(Objects::requireNonNull); - this.streams = streams; - } - - @Override - public void write(int b) throws IOException { - for (final var out : streams) { - out.write(b); - } - } - - @Override - public void write(byte[] b) throws IOException { - for (final var out : streams) { - out.write(b); - } - } - - @Override - public void write(byte[] b, int off, int len) throws IOException { - for (final var out : streams) { - out.write(b, off, len); - } - } - - @Override - public void flush() throws IOException { - forEach(OutputStream::flush); - } - - @Override - public void close() throws IOException { - forEach(OutputStream::close); - } - - private void forEach(OutputStreamConsumer c) throws IOException { - IOException firstEx = null; - for (final var out : streams) { - try { - c.accept(out); - } catch (IOException e) { - if (firstEx == null) { - firstEx = e; - } - } - } - if (firstEx != null) { - throw firstEx; - } - } - - @FunctionalInterface - private static interface OutputStreamConsumer { - void accept(OutputStream out) throws IOException; - } - - private final Iterable streams; - } - - private static final class CommandOutput { - CommandOutput(Optional> lines, int stdoutLineCount) { - this.lines = Objects.requireNonNull(lines); - this.stdoutLineCount = stdoutLineCount; - } - - CommandOutput() { - this(Optional.empty(), 0); - } - - Optional> lines() { - return lines; - } - - Optional> stdoutLines() { - if (lines.isEmpty() || stdoutLineCount < 0) { - return Optional.empty(); - } - - final var theLines = lines.orElseThrow(); - if (stdoutLineCount == theLines.size()) { - return lines; - } else { - return Optional.of(theLines.subList(0, Integer.min(stdoutLineCount, theLines.size()))); - } - } - - Optional> stderrLines() { - if (lines.isEmpty() || stdoutLineCount > lines.orElseThrow().size()) { - return Optional.empty(); - } else if (stdoutLineCount == 0) { - return lines; - } else { - final var theLines = lines.orElseThrow(); - return Optional.of(theLines.subList(stdoutLineCount, theLines.size())); - } - } - - private final Optional> lines; - private final int stdoutLineCount; - - static final CommandOutput EMPTY = new CommandOutput(); - } - private ToolProvider toolProvider; private Path executable; - private OutputStreamsControl outputStreamsControl; + private final CommandOutputControl commandOutputControl = new CommandOutputControl(); private Path directory; private Set removeEnvVars = new HashSet<>(); private Map setEnvVars = new HashMap<>(); private boolean winEnglishOutput; private String winTmpDir = null; - - private static enum OutputControlOption { - SAVE_ALL, SAVE_FIRST_LINE, DUMP - } } diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java index 38091cb3452..c5c4f87b097 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -52,6 +52,8 @@ import java.util.Optional; import java.util.OptionalInt; import java.util.Set; import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -780,10 +782,9 @@ public class JPackageCommand extends CommandArguments { } /** - * Starts a new thread. In this thread calls - * {@link #useToolProviderByDefault(ToolProvider)} with the specified - * {@code jpackageToolProvider} and then calls {@code workload.run()}. Joins the - * thread. + * In a separate thread calls {@link #useToolProviderByDefault(ToolProvider)} + * with the specified {@code jpackageToolProvider} and then calls + * {@code workload.run()}. Joins the thread. *

* The idea is to run the {@code workload} in the context of the specified * jpackage {@code ToolProvider} without altering the global variable holding @@ -794,13 +795,23 @@ public class JPackageCommand extends CommandArguments { * @param jpackageToolProvider jpackage {@code ToolProvider} * @param workload the workload to run */ - public static void withToolProvider(ToolProvider jpackageToolProvider, Runnable workload) { - Objects.requireNonNull(jpackageToolProvider); + public static void withToolProvider(Runnable workload, ToolProvider jpackageToolProvider) { Objects.requireNonNull(workload); - ThrowingRunnable.toRunnable(Thread.ofVirtual().start(() -> { + Objects.requireNonNull(jpackageToolProvider); + + CompletableFuture.runAsync(() -> { + var oldValue = defaultToolProvider.get(); useToolProviderByDefault(jpackageToolProvider); - workload.run(); - })::join).run(); + try { + workload.run(); + } finally { + defaultToolProvider.set(oldValue); + } + // Run the future in a new native thread. Don't run it in a virtual/pooled thread. + // Pooled and/or virtual threads are problematic when used with inheritable thread-local variables. + // TKit class depends on such a variable, which results in intermittent test failures + // if the default executor runs this future. + }, Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory())).join(); } public JPackageCommand useToolProvider(boolean v) { @@ -1022,7 +1033,7 @@ public class JPackageCommand extends CommandArguments { outputValidator.accept(result.getOutput().iterator()); } - if (result.exitCode() == 0 && expectedExitCode.isPresent()) { + if (result.getExitCode() == 0 && expectedExitCode.isPresent()) { verifyActions.run(); } diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/LinuxHelper.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/LinuxHelper.java index 3b02a3f6a69..78562b2ed26 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/LinuxHelper.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/LinuxHelper.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -723,7 +723,9 @@ public final class LinuxHelper { private static Optional queryMimeTypeDefaultHandler(String mimeType) { return Executor.of("xdg-mime", "query", "default", mimeType) - .discardStderr().saveFirstLineOfOutput().execute().findFirstLineOfOutput(); + .discardStderr() + .saveFirstLineOfOutput() + .execute().getOutput().stream().findFirst(); } private static void verifyIconInScriptlet(Scriptlet scriptletType, diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacHelper.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacHelper.java index 92d9fa0bd44..6a5be77457a 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacHelper.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacHelper.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -49,11 +49,11 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; import java.util.Properties; import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; @@ -66,10 +66,10 @@ import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathFactory; -import jdk.jpackage.internal.RetryExecutor; import jdk.jpackage.internal.util.FileUtils; import jdk.jpackage.internal.util.PListReader; import jdk.jpackage.internal.util.PathUtils; +import jdk.jpackage.internal.util.RetryExecutor; import jdk.jpackage.internal.util.XmlUtils; import jdk.jpackage.internal.util.function.ThrowingConsumer; import jdk.jpackage.internal.util.function.ThrowingSupplier; @@ -90,38 +90,34 @@ public final class MacHelper { final var mountRoot = TKit.createTempDirectory("mountRoot"); // Explode DMG assuming this can require interaction, thus use `yes`. - String attachCMD[] = { - "sh", "-c", - String.join(" ", "yes", "|", "/usr/bin/hdiutil", "attach", - JPackageCommand.escapeAndJoin(cmd.outputBundle().toString()), - "-mountroot", PathUtils.normalizedAbsolutePathString(mountRoot), - "-nobrowse", "-plist")}; - RetryExecutor attachExecutor = new RetryExecutor(); - try { - // 10 times with 6 second delays. - attachExecutor.setMaxAttemptsCount(10) - .setAttemptTimeoutMillis(6000) - .setWriteOutputToFile(true) - .saveOutput(true) - .execute(attachCMD); - } catch (IOException ex) { - throw new RuntimeException(ex); - } + final var attachStdout = Executor.of("sh", "-c", String.join(" ", + "yes", + "|", + "/usr/bin/hdiutil", + "attach", + JPackageCommand.escapeAndJoin(cmd.outputBundle().toString()), + "-mountroot", PathUtils.normalizedAbsolutePathString(mountRoot), + "-nobrowse", + "-plist" + )).saveOutput().storeOutputInFiles().executeAndRepeatUntilExitCode(0, 10, 6).stdout(); - Path mountPoint = null; + final Path mountPoint; + + boolean mountPointInitialized = false; try { // One of "dict" items of "system-entities" array property should contain "mount-point" string property. - mountPoint = readPList(attachExecutor.getOutput()).queryArrayValue("system-entities", false).map(PListReader.class::cast).map(dict -> { - try { - return dict.queryValue("mount-point"); - } catch (NoSuchElementException ex) { - return (String)null; - } - }).filter(Objects::nonNull).map(Path::of).findFirst().orElseThrow(); + mountPoint = readPList(attachStdout).queryArrayValue("system-entities", false) + .map(PListReader.class::cast) + .map(dict -> { + return dict.findValue("mount-point"); + }) + .filter(Optional::isPresent).map(Optional::get) + .map(Path::of).findFirst().orElseThrow(); + mountPointInitialized = true; } finally { - if (mountPoint == null) { + if (!mountPointInitialized) { TKit.trace("Unexpected plist file missing `system-entities` array:"); - attachExecutor.getOutput().forEach(TKit::trace); + attachStdout.forEach(TKit::trace); TKit.trace("Done"); } } @@ -138,39 +134,27 @@ public final class MacHelper { ThrowingConsumer.toConsumer(consumer).accept(childPath); } } finally { - String detachCMD[] = { - "/usr/bin/hdiutil", - "detach", - "-verbose", - mountPoint.toAbsolutePath().toString()}; // "hdiutil detach" might not work right away due to resource busy error, so // repeat detach several times. - RetryExecutor detachExecutor = new RetryExecutor(); - // Image can get detach even if we got resource busy error, so stop - // trying to detach it if it is no longer attached. - final Path mp = mountPoint; - detachExecutor.setExecutorInitializer(exec -> { - if (!Files.exists(mp)) { - detachExecutor.abort(); + new RetryExecutor(RuntimeException.class).setExecutable(context -> { + var exec = Executor.of("/usr/bin/hdiutil", "detach").storeOutputInFiles(); + if (context.isLastAttempt()) { + // The last attempt, force detach. + exec.addArgument("-force"); } - }); - try { - // 10 times with 6 second delays. - detachExecutor.setMaxAttemptsCount(10) - .setAttemptTimeoutMillis(6000) - .setWriteOutputToFile(true) - .saveOutput(true) - .execute(detachCMD); - } catch (IOException ex) { - if (!detachExecutor.isAborted()) { - // Now force to detach if it still attached - if (Files.exists(mountPoint)) { - Executor.of("/usr/bin/hdiutil", "detach", - "-force", "-verbose") - .addArgument(mountPoint).execute(); - } + exec.addArgument(mountPoint); + + // The image can get detached even if we get a resource busy error, + // so execute the detach command without checking the exit code. + var result = exec.executeWithoutExitCodeCheck(); + + if (result.getExitCode() == 0 || !Files.exists(mountPoint)) { + // Detached successfully! + return null; + } else { + throw new RuntimeException(String.format("[%s] mount point still attached", mountPoint)); } - } + }).setMaxAttemptsCount(10).setAttemptTimeout(6, TimeUnit.SECONDS).execute(); } } diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacSign.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacSign.java index 15249c51887..70de6ba92af 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacSign.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacSign.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -1172,7 +1172,7 @@ public final class MacSign { "-c", certFile.normalize().toString(), "-k", keychain.name(), "-p", resolvedCertificateRequest.installed().type().verifyPolicy()).saveOutput(!quite).executeWithoutExitCodeCheck(); - if (result.exitCode() == 0) { + if (result.getExitCode() == 0) { return VerifyStatus.VERIFY_OK; } } diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacSignVerify.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacSignVerify.java index 1f37829791e..0ecfd4c3432 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacSignVerify.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/MacSignVerify.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -94,7 +94,7 @@ public final class MacSignVerify { public static Optional findEntitlements(Path path) { final var exec = Executor.of("/usr/bin/codesign", "-d", "--entitlements", "-", "--xml", path.toString()).saveOutput().dumpOutput(); final var result = exec.execute(); - var xml = result.stdout().getOutput(); + var xml = result.stdout(); if (xml.isEmpty()) { return Optional.empty(); } else { @@ -137,7 +137,7 @@ public final class MacSignVerify { public static Optional findSpctlSignOrigin(SpctlType type, Path path) { final var exec = Executor.of("/usr/sbin/spctl", "-vv", "--raw", "--assess", "--type", type.value(), path.toString()).saveOutput().discardStderr(); final var result = exec.executeWithoutExitCodeCheck(); - TKit.assertTrue(Set.of(0, 3).contains(result.exitCode()), + TKit.assertTrue(Set.of(0, 3).contains(result.getExitCode()), String.format("Check exit code of command %s is either 0 or 3", exec.getPrintableCommandLine())); return toSupplier(() -> { try { @@ -173,7 +173,7 @@ public final class MacSignVerify { } else if (result.getExitCode() == 1 && result.getFirstLineOfOutput().endsWith("code object is not signed at all")) { return Optional.empty(); } else { - reportUnexpectedCommandOutcome(exec.getPrintableCommandLine(), result); + reportUnexpectedCommandOutcome(result); return null; // Unreachable } } @@ -205,7 +205,7 @@ public final class MacSignVerify { TKit.trace("Try /usr/bin/codesign again with `sudo`"); assertSigned(path, true); } else { - reportUnexpectedCommandOutcome(exec.getPrintableCommandLine(), result); + reportUnexpectedCommandOutcome(result); } } @@ -264,13 +264,13 @@ public final class MacSignVerify { return signIdentities; } catch (Exception ex) { ex.printStackTrace(); - reportUnexpectedCommandOutcome(exec.getPrintableCommandLine(), result); + reportUnexpectedCommandOutcome(result); return null; // Unreachable } } else if (result.getExitCode() == 1 && result.getOutput().getLast().endsWith("Status: no signature")) { return List.of(); } else { - reportUnexpectedCommandOutcome(exec.getPrintableCommandLine(), result); + reportUnexpectedCommandOutcome(result); return null; // Unreachable } } @@ -282,14 +282,13 @@ public final class MacSignVerify { } } - private static void reportUnexpectedCommandOutcome(String printableCommandLine, Executor.Result result) { - Objects.requireNonNull(printableCommandLine); + private static void reportUnexpectedCommandOutcome(Executor.Result result) { Objects.requireNonNull(result); TKit.trace(String.format("Command %s exited with exit code %d and the following output:", - printableCommandLine, result.getExitCode())); + result.getPrintableCommandLine(), result.getExitCode())); result.getOutput().forEach(TKit::trace); TKit.trace("Done"); - TKit.assertUnexpected(String.format("Outcome of command %s", printableCommandLine)); + TKit.assertUnexpected(String.format("Outcome of command %s", result.getPrintableCommandLine())); } private static final Pattern SIGN_IDENTITY_NAME_REGEXP = Pattern.compile("^\\s+\\d+\\.\\s+(.*)$"); diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/WindowsHelper.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/WindowsHelper.java index 72b5dbc578b..f9fbf285b49 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/WindowsHelper.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/WindowsHelper.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -81,16 +81,16 @@ public class WindowsHelper { msiLog.ifPresent(v -> misexec.clearArguments().addArguments(origArgs).addArgument("/L*v").addArgument(v)); result = misexec.executeWithoutExitCodeCheck(); - if (result.exitCode() == 1605) { + if (result.getExitCode() == 1605) { // ERROR_UNKNOWN_PRODUCT, attempt to uninstall not installed // package - return result.exitCode(); + return result.getExitCode(); } // The given Executor may either be of an msiexec command or an // unpack.bat script containing the msiexec command. In the later // case, when misexec returns 1618, the unpack.bat may return 1603 - if ((result.exitCode() == 1618) || (result.exitCode() == 1603 && isUnpack)) { + if ((result.getExitCode() == 1618) || (result.getExitCode() == 1603 && isUnpack)) { // Another installation is already in progress. // Wait a little and try again. Long timeout = 1000L * (attempt + 3); // from 3 to 10 seconds @@ -100,7 +100,7 @@ public class WindowsHelper { break; } - return result.exitCode(); + return result.getExitCode(); } static PackageHandlers createMsiPackageHandlers(boolean createMsiLog) { @@ -462,7 +462,7 @@ public class WindowsHelper { var status = Executor.of("reg", "query", keyPath, "/v", valueName) .saveOutput() .executeWithoutExitCodeCheck(); - if (status.exitCode() == 1) { + if (status.getExitCode() == 1) { // Should be the case of no such registry value or key String lookupString = "ERROR: The system was unable to find the specified registry key or value."; TKit.assertTextStream(lookupString) diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandAction.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandAction.java new file mode 100644 index 00000000000..d9ab38e006a --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandAction.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.io.PrintStream; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * An action. + */ +@FunctionalInterface +public interface CommandAction { + + public record Context(PrintStream out, PrintStream err, List args) { + + public Context { + Objects.requireNonNull(out); + Objects.requireNonNull(err); + args.forEach(Objects::requireNonNull); + } + + public Optional findOptionValue(String option) { + Objects.requireNonNull(option); + var idx = args.indexOf(option); + if (idx >= 0 && idx + 1 < args.size()) { + return Optional.of(args.get(idx + 1)); + } else { + return Optional.empty(); + } + } + + public String optionValue(String option) { + return findOptionValue(option).orElseThrow(() -> { + throw new MockIllegalStateException(String.format("No option %s", option)); + }); + } + + public MockIllegalStateException unexpectedArguments() { + return new MockIllegalStateException(String.format("Unexpected arguments: %s", args)); + } + } + + /** + * Runs the action in the given context. + * + * @param context the context + * @return an {@code Optional} wrapping the exit code, indicating it is the last + * action in the sequence or an empty {@code Optional} otherwise + * @throws Exception simulates a failure + * @throws MockIllegalStateException if error in internal mock logic occurred. + * E.g.: if the action was called unexpectedly + */ + Optional run(Context context) throws Exception, MockIllegalStateException; +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandActionSpec.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandActionSpec.java new file mode 100644 index 00000000000..25814d84205 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandActionSpec.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.util.Objects; +import java.util.Optional; +import jdk.jpackage.internal.util.function.ThrowingSupplier; +import jdk.jpackage.internal.util.function.ThrowingConsumer; +import jdk.jpackage.internal.util.function.ThrowingRunnable; + +/** + * Specification of a {@link CommandAction}. + *

+ * Comprised of a human-readable description and an associated action. + */ +public interface CommandActionSpec { + + String description(); + CommandAction action(); + + public static CommandActionSpec create(String description, CommandAction action) { + return new Internal.DefaultCommandActionSpec(description, action); + } + + public static CommandActionSpec create(String description, ThrowingSupplier action) { + Objects.requireNonNull(action); + return create(description, _ -> { + return Optional.of(action.get()); + }); + } + + public static CommandActionSpec create(String description, ThrowingRunnable action) { + Objects.requireNonNull(action); + return create(description, _ -> { + action.run(); + return Optional.empty(); + }); + } + + @SuppressWarnings("overloads") + public static CommandActionSpec create(String description, ThrowingConsumer action) { + Objects.requireNonNull(action); + return create(description, context -> { + action.accept(context); + return Optional.empty(); + }); + } + + final class Internal { + + private Internal() { + } + + private record DefaultCommandActionSpec(String description, CommandAction action) implements CommandActionSpec { + DefaultCommandActionSpec { + Objects.requireNonNull(description); + Objects.requireNonNull(action); + } + + @Override + public String toString() { + return description(); + } + } + } +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandActionSpecs.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandActionSpecs.java new file mode 100644 index 00000000000..e89e458c02d --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandActionSpecs.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.stream.Stream; +import jdk.jpackage.internal.util.function.ExceptionBox; + +/** + * A sequence of actions. + */ +public record CommandActionSpecs(List specs) { + + public CommandActionSpecs { + Objects.requireNonNull(specs); + } + + public CommandActionSpecs andThen(CommandActionSpecs other) { + return build().append(this).append(other).create(); + } + + public Stream actions() { + return specs.stream().map(CommandActionSpec::action); + } + + public CommandMock.Builder toCommandMockBuilder() { + return new CommandMock.Builder().mutate(builder -> { + builder.actions().append(this); + }); + } + + @Override + public String toString() { + return specs.toString(); + } + + public static Builder build() { + return new Builder(); + } + + public static final class Builder { + + public CommandActionSpecs create() { + return new CommandActionSpecs(List.copyOf(specs)); + } + + public Builder stdout(List content) { + Objects.requireNonNull(content); + return action(CommandActionSpec.create(String.format("%s>>1", content), context -> { + var out = context.out(); + content.forEach(out::println); + })); + } + + public Builder stdout(String... str) { + return stdout(List.of(str)); + } + + public Builder stderr(List content) { + Objects.requireNonNull(content); + return action(CommandActionSpec.create(String.format("%s>>2", content), context -> { + var err = context.err(); + content.forEach(err::println); + })); + } + + public Builder stderr(String... str) { + return stderr(List.of(str)); + } + + public Builder printToStdout(List content) { + Objects.requireNonNull(content); + return action(CommandActionSpec.create(String.format("%s(no-eol)>>1", content), context -> { + var out = context.out(); + content.forEach(out::print); + })); + } + + public Builder printToStdout(String... str) { + return printToStdout(List.of(str)); + } + + public Builder printToStderr(List content) { + Objects.requireNonNull(content); + return action(CommandActionSpec.create(String.format("%s(no-eol)>>2", content), context -> { + var err = context.err(); + content.forEach(err::print); + })); + } + + public Builder printToStderr(String... str) { + return printToStderr(List.of(str)); + } + + public Builder exit(int exitCode) { + return action(CommandActionSpec.create(String.format("exit(%d)", exitCode), () -> { + return exitCode; + })); + } + + public Builder exit() { + return exit(0); + } + + public Builder exit(CommandMockExit exit) { + switch (exit) { + case SUCCEED -> { + return exit(); + } + case EXIT_1 -> { + return exit(1); + } + case THROW_MOCK_IO_EXCEPTION -> { + return action(CommandActionSpec.create("", () -> { + throw new MockingToolProvider.RethrowableException(new MockIOException("Kaput!")); + })); + } + default -> { + throw ExceptionBox.reachedUnreachable(); + } + } + } + + public Builder mutate(Consumer mutator) { + mutator.accept(this); + return this; + } + + public Builder append(Builder other) { + return append(other.specs); + } + + public Builder append(CommandActionSpecs other) { + return append(other.specs()); + } + + public Builder append(List other) { + specs.addAll(other); + return this; + } + + public Builder action(CommandActionSpec v) { + specs.add(Objects.requireNonNull(v)); + return this; + } + + public Builder copy() { + return new Builder().append(this); + } + + public CommandMock.Builder toCommandMockBuilder() { + return new CommandMock.Builder().mutate(builder -> { + builder.actions(this); + }); + } + + private final List specs = new ArrayList<>(); + } + + public static final CommandActionSpecs UNREACHABLE = new CommandActionSpecs(List.of()); +} + diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandMock.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandMock.java new file mode 100644 index 00000000000..bb3980fd4e0 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandMock.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.IntStream; + +/** + * Command mock. + */ +public sealed interface CommandMock permits ToolProviderCommandMock, VerbatimCommandMock, CompletableCommandMock { + + public static CommandMock ioerror(String name) { + return CommandActionSpecs.build() + .exit(CommandMockExit.THROW_MOCK_IO_EXCEPTION) + .toCommandMockBuilder().name(Objects.requireNonNull(name)).create(); + } + + public static CommandMock fail(String name) { + return CommandActionSpecs.build() + .exit(CommandMockExit.EXIT_1) + .toCommandMockBuilder().name(Objects.requireNonNull(name)).create(); + } + + public static CommandMock succeed(String name) { + return CommandActionSpecs.build() + .exit(CommandMockExit.SUCCEED) + .toCommandMockBuilder().name(Objects.requireNonNull(name)).create(); + } + + public static CommandMock unreachable() { + return MockingToolProvider.UNREACHABLE; + } + + public final class Builder { + + public ToolProviderCommandMock create() { + + var actionSpecs = Optional.ofNullable(scriptBuilder) + .map(CommandActionSpecs.Builder::create) + .orElse(CommandActionSpecs.UNREACHABLE); + if (actionSpecs.equals(CommandActionSpecs.UNREACHABLE)) { + return (ToolProviderCommandMock)unreachable(); + } + + var theName = Optional.ofNullable(name).orElse("mock"); + var script = actionSpecs.actions().toList(); + switch (repeat) { + case 0 -> { + return MockingToolProvider.create(theName, script); + } + case -1 -> { + return MockingToolProvider.createLoop(theName, script); + } + default -> { + var repeatedScript = IntStream.rangeClosed(0, repeat) + .mapToObj(i -> script) + .flatMap(List::stream) + .toList(); + return MockingToolProvider.create(theName, repeatedScript); + } + } + } + + public Builder name(String v) { + name = v; + return this; + } + + public Builder mutate(Consumer mutator) { + mutator.accept(this); + return this; + } + + public Builder repeat(int v) { + repeat = Integer.max(-1, v); + return this; + } + + public Builder noRepeats() { + return repeat(0); + } + + public Builder repeatInfinitely() { + return repeat(-1); + } + + public Builder actions(CommandActionSpecs.Builder v) { + scriptBuilder = Optional.ofNullable(v).orElseGet(CommandActionSpecs::build); + return this; + } + + public CommandActionSpecs.Builder actions() { + if (scriptBuilder == null) { + scriptBuilder = CommandActionSpecs.build(); + } + return scriptBuilder; + } + + private String name; + private int repeat = -1; + private CommandActionSpecs.Builder scriptBuilder; + } + +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandMockExit.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandMockExit.java new file mode 100644 index 00000000000..26bc8ba757b --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandMockExit.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import jdk.jpackage.internal.util.CommandOutputControl; + +public enum CommandMockExit { + /** + * Exit normally with "0" exit code. + */ + SUCCEED(true, true), + /** + * Exit normally with "1" exit code. + */ + EXIT_1(false, true), + /** + * Throw {@link MockIOException}. This simulates a situation when an I/O error + * occurs starting a subprocess with {@link ProcessBuilder#start()}. + * {@link CommandOutputControl.Executable#execute()} will handle I/O errors and + * let them out. + */ + THROW_MOCK_IO_EXCEPTION(false, false), + ; + + CommandMockExit(boolean succeed, boolean exitNormally) { + this.succeed = succeed; + this.exitNormally = exitNormally; + } + + public boolean succeed() { + return succeed; + } + + public boolean exitNormally() { + return exitNormally; + } + + private final boolean succeed; + private final boolean exitNormally; +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandMockSpec.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandMockSpec.java new file mode 100644 index 00000000000..2572309e751 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CommandMockSpec.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.nio.file.Path; +import java.util.Objects; + +/** + * Specification of a {@link CommandMock}. + */ +public record CommandMockSpec(Path name, Path mockName, CommandActionSpecs actions) { + + public CommandMockSpec { + Objects.requireNonNull(name); + Objects.requireNonNull(mockName); + Objects.requireNonNull(actions); + } + + public CommandMockSpec(Path name, CommandActionSpecs actions) { + this(name, Path.of(name.toString() + "-mock"), actions); + } + + public CommandMockSpec(String name, CommandActionSpecs actions) { + this(Path.of(name), actions); + } + + public CommandMockSpec(String name, String mockName, CommandActionSpecs actions) { + this(Path.of(name), Path.of(mockName), actions); + } + + public CommandMock.Builder toCommandMockBuilder() { + return actions.toCommandMockBuilder().name(mockName.toString()); + } + + public boolean isDefaultMockName() { + return (name.getFileName().toString() + "-mock").equals(mockName.getFileName().toString()); + } + + @Override + public String toString() { + return String.format("mock-of(%s)%s", name, actions); + } +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CompletableCommandMock.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CompletableCommandMock.java new file mode 100644 index 00000000000..c9441e038b3 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/CompletableCommandMock.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +/** + * Command mock that runs a finite sequence of actions. + */ +public sealed interface CompletableCommandMock extends CommandMock permits ToolProviderCompletableCommandMock { + + boolean completed(); +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/MockIOException.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/MockIOException.java new file mode 100644 index 00000000000..3b299f05d3f --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/MockIOException.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.io.IOException; + +/** + * Simulates I/O error. + * + * @see CommandMockExit#THROW_MOCK_IO_EXCEPTION + */ +public final class MockIOException extends IOException { + + MockIOException(String msg) { + super(msg); + } + + private static final long serialVersionUID = 1L; +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/MockIllegalStateException.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/MockIllegalStateException.java new file mode 100644 index 00000000000..1817587364a --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/MockIllegalStateException.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +/** + * Indicates command mock internal error. + */ +public final class MockIllegalStateException extends IllegalStateException { + + public MockIllegalStateException(String msg) { + super(msg); + } + + private static final long serialVersionUID = 1L; +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/MockingToolProvider.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/MockingToolProvider.java new file mode 100644 index 00000000000..f8c04cc3927 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/MockingToolProvider.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.io.PrintStream; +import java.io.PrintWriter; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import jdk.jpackage.internal.util.function.ExceptionBox; + +/** + * A command simulator implementing {@code ToolProvider}. + *

+ * Iterates over actions and runs them. Each action is write to stdout/stderr, create a file, etc. + */ +abstract sealed class MockingToolProvider implements ToolProviderCommandMock { + + MockingToolProvider(String name, Iterator actionIter) { + this.name = Objects.requireNonNull(name); + this.actionIter = Objects.requireNonNull(actionIter); + } + + static ToolProviderCommandMock createLoop(String name, Iterable actions) { + return new MockingToolProvider.NonCompletable(name, actions); + } + + static MockingToolProvider create(String name, Iterable actions) { + return new MockingToolProvider.Completable(name, actions); + } + + public boolean completed() { + return !actionIter.hasNext(); + } + + @Override + public String name() { + return name; + } + + @Override + public int run(PrintStream out, PrintStream err, String... args) { + var context = new CommandAction.Context(out, err, List.of(args)); + try { + while (actionIter.hasNext()) { + var action = actionIter.next(); + var reply = action.run(context); + if (reply.isPresent()) { + return reply.get(); + } + } + } catch (RethrowableException ex) { + // Let the checked exception out. + throwAny(ex.getCause()); + // Unreachable + return 0; + } catch (Exception ex) { + throw ExceptionBox.toUnchecked(ex); + } + + // No more actions to execute, but still expect it to keep going. + throw new MockIllegalStateException("No more actions to execute"); + } + + @Override + public int run(PrintWriter out, PrintWriter err, String... args) { + throw new UnsupportedOperationException(); + } + + static final class RethrowableException extends Exception { + + RethrowableException(Exception ex) { + super(Objects.requireNonNull(ex)); + } + + private static final long serialVersionUID = 1L; + } + + @SuppressWarnings("unchecked") + private static void throwAny(Throwable e) throws E { + throw (E)e; + } + + private static final class LoopIterator implements Iterator { + + LoopIterator(Iterable iterable) { + this.iterable = Objects.requireNonNull(iterable); + rewind(); + } + + @Override + public boolean hasNext() { + return iter != null; + } + + @Override + public T next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } else if (iter.hasNext()) { + return iter.next(); + } else { + rewind(); + if (!hasNext()) { + throw new NoSuchElementException(); + } else { + return iter.next(); + } + } + } + + private void rewind() { + iter = Objects.requireNonNull(iterable.iterator()); + if (!iter.hasNext()) { + iter = null; + } + } + + private final Iterable iterable; + private Iterator iter; + } + + static final class NonCompletable extends MockingToolProvider { + + NonCompletable(String name, Iterable actions) { + super(name, new LoopIterator<>(actions)); + } + + } + + static final class Completable extends MockingToolProvider implements ToolProviderCompletableCommandMock { + + Completable(String name, Iterable actions) { + super(name, actions.iterator()); + } + + } + + private final String name; + private final Iterator actionIter; + + static ToolProviderCommandMock UNREACHABLE = new MockingToolProvider.NonCompletable("", List.of()); +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/Script.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/Script.java new file mode 100644 index 00000000000..bc51d2b69f8 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/Script.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; +import jdk.jpackage.internal.util.IdentityWrapper; + +/** + * Script of command mocks. + */ +public interface Script { + + /** + * Returns a command mock for the given command line. + * + * @param cmdline the command line for which to look up a command mock + * + * @return a command mock matching the given command line + * @throws ScriptException if an internal script error occures + */ + CommandMock map(List cmdline) throws ScriptException; + + /** + * Returns command mocks registered with this object that have not completed yet. + * + * @See {@link CompletableCommandMock#completed()} + * + * @return the command mocks registered with this object that have not completed yet + */ + Collection incompleteMocks(); + + public static Builder build() { + return new Builder(); + } + + public static Predicate> cmdlinePredicate( + Predicate pred, + Function conv, + Function, Stream> toStream) { + + Objects.requireNonNull(pred); + Objects.requireNonNull(conv); + Objects.requireNonNull(toStream); + + return cmdline -> { + return toStream.apply(cmdline).map(conv).filter(pred).findFirst().isPresent(); + }; + } + + public static Predicate> cmdlineContains(String arg) { + return cmdlinePredicate(Predicate.isEqual(Objects.requireNonNull(arg)), x -> x, List::stream); + } + + public static Predicate> cmdlineContains(Path arg) { + return cmdlinePredicate(Predicate.isEqual(Objects.requireNonNull(arg)), Path::of, List::stream); + } + + public static Predicate> cmdlineStartsWith(String arg) { + return cmdlinePredicate(Predicate.isEqual(Objects.requireNonNull(arg)), x -> x, cmdline -> { + return cmdline.stream().limit(1); + }); + } + + public static Predicate> cmdlineStartsWith(Path arg) { + return cmdlinePredicate(Predicate.isEqual(Objects.requireNonNull(arg)), Path::of, cmdline -> { + return cmdline.stream().limit(1); + }); + } + + public final class ScriptException extends RuntimeException { + + ScriptException(RuntimeException cause) { + super(Objects.requireNonNull(cause)); + } + + ScriptException(String msg) { + super(Objects.requireNonNull(msg)); + } + + private static final long serialVersionUID = 1L; + } + + public final class Builder { + + public Script createSequence() { + return new SequenceScript(List.copyOf(instructions), completableMocks()); + } + + public Script createLoop() { + return new LoopScript(List.copyOf(instructions), completableMocks()); + } + + public Builder map(Predicate> pred, CommandMock mock) { + Objects.requireNonNull(pred); + Objects.requireNonNull(mock); + if (mock instanceof CompletableCommandMock completable) { + completableMocks.add(new IdentityWrapper<>(completable)); + } + instruction(cmdline -> { + if (pred.test(cmdline)) { + return new CommandMockResult(Optional.of(mock)); + } else { + return new CommandMockResult(Optional.empty()); + } + }); + return this; + } + + public Builder map(Predicate> pred, CommandMock.Builder mock) { + Optional.ofNullable(commandMockBuilderMutator).ifPresent(mock::mutate); + return map(pred, mock.create()); + } + + public Builder map(Predicate> pred, CommandMockSpec mock) { + return map(pred, mock.toCommandMockBuilder()); + } + + public Builder map(CommandMockSpec mock) { + return map(cmdlineStartsWith(mock.name()), mock.toCommandMockBuilder()); + } + + public Builder use(CommandMock mock) { + return map(_ -> true, mock); + } + + public Builder use(Predicate> pred, CommandMock.Builder mock) { + return map(_ -> true, mock); + } + + public Builder use(Predicate> pred, CommandMockSpec mock) { + return map(_ -> true, mock); + } + + public Builder branch(Predicate> pred, Script script) { + Objects.requireNonNull(pred); + Objects.requireNonNull(script); + instruction(cmdline -> { + if (pred.test(cmdline)) { + return new ScriptResult(script); + } else { + return new CommandMockResult(Optional.empty()); + } + }); + return this; + } + + public Builder commandMockBuilderMutator(Consumer v) { + commandMockBuilderMutator = v; + return this; + } + + public Builder mutate(Consumer mutator) { + mutator.accept(this); + return this; + } + + private Builder instruction(Function, Result> instruction) { + instructions.add(Objects.requireNonNull(instruction)); + return this; + } + + private Collection completableMocks() { + return completableMocks.stream().map(IdentityWrapper::value).toList(); + } + + private static RuntimeException noMapping(List cmdline) { + return new ScriptException(String.format("Mapping for %s command line not found", cmdline)); + } + + private sealed interface Result { + } + + private record CommandMockResult(Optional value) implements Result { + CommandMockResult { + Objects.requireNonNull(value); + } + } + + private record ScriptResult(Script value) implements Result { + ScriptResult { + Objects.requireNonNull(value); + } + } + + private abstract static class AbstractScript implements Script { + + AbstractScript(Collection completableMocks) { + this.completableMocks = Objects.requireNonNull(completableMocks); + } + + @Override + public Collection incompleteMocks() { + return completableMocks.stream().filter(Predicate.not(CompletableCommandMock::completed)).toList(); + } + + private final Collection completableMocks; + } + + private static final class LoopScript extends AbstractScript { + + LoopScript(List, Result>> instructions, + Collection completableMocks) { + super(completableMocks); + this.instructions = Objects.requireNonNull(instructions); + } + + @Override + public CommandMock map(List cmdline) { + for (var instruction : instructions) { + switch (instruction.apply(cmdline)) { + case CommandMockResult result -> { + var mock = result.value(); + if (mock.isPresent()) { + return mock.get(); + } + } + case ScriptResult result -> { + return result.value().map(cmdline); + } + } + } + + throw noMapping(cmdline); + } + + private final List, Result>> instructions; + } + + private static final class SequenceScript extends AbstractScript { + + SequenceScript(List, Result>> instructions, + Collection completableMocks) { + super(completableMocks); + this.iter = instructions.iterator(); + } + + @Override + public CommandMock map(List cmdline) { + if (!iter.hasNext()) { + throw new ScriptException("No more mappings"); + } else { + switch (iter.next().apply(cmdline)) { + case CommandMockResult result -> { + var mock = result.value(); + if (mock.isPresent()) { + return mock.get(); + } + } + case ScriptResult result -> { + return result.value().map(cmdline); + } + } + } + + throw noMapping(cmdline); + } + + private final Iterator, Result>> iter; + } + + private Consumer commandMockBuilderMutator = CommandMock.Builder::noRepeats; + private final List, Result>> instructions = new ArrayList<>(); + private final Set> completableMocks = new HashSet<>(); + } +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ScriptSpec.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ScriptSpec.java new file mode 100644 index 00000000000..60e5723e9a7 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ScriptSpec.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.IntStream; + +/** + * Specification of a {@link Script}. + */ +public record ScriptSpec(List items, boolean loop) { + + public ScriptSpec { + Objects.requireNonNull(items); + } + + @Override + public String toString() { + var sb = new StringBuilder(); + sb.append(items.toString()); + if (loop) { + // Append "Clockwise Gapped Circle Arrow" Unicode symbol. + sb.append('(').appendCodePoint(0x27F3).append(')'); + } + return sb.toString(); + } + + public Script create() { + var script = Script.build(); + items.forEach(item -> { + item.applyTo(script, loop); + }); + if (loop) { + return script.createLoop(); + } else { + return script.createSequence(); + } + } + + public Collection commandNames() { + return items.stream().map(Item::mockSpec).map(CommandMockSpec::name).distinct().toList(); + } + + private record Item(CommandMockSpec mockSpec, int repeatCount, boolean detailedDescription) { + + private Item { + Objects.requireNonNull(mockSpec); + if (repeatCount < 0) { + throw new IllegalArgumentException(); + } + } + + @Override + public String toString() { + var sb = new StringBuilder(); + if (detailedDescription) { + sb.append(mockSpec); + } else if (mockSpec.isDefaultMockName()) { + sb.append(mockSpec.name()); + } else { + sb.append(mockSpec.mockName()); + } + if (repeatCount > 0) { + sb.append('(').append(repeatCount + 1).append(')'); + } + return sb.toString(); + } + + void applyTo(Script.Builder script, boolean loopScript) { + var pred = Script.cmdlineStartsWith(mockSpec.name()); + + var mockBuilder = mockSpec.toCommandMockBuilder(); + if (loopScript) { + script.map(pred, mockBuilder.repeat(repeatCount).create()); + } else { + mockBuilder.repeat(0); + IntStream.rangeClosed(0, repeatCount).forEach(_ -> { + script.map(pred, mockBuilder.create()); + }); + } + } + + } + + public static Builder build() { + return new Builder(); + } + + public static final class Builder { + + private Builder() { + } + + public ScriptSpec create() { + return new ScriptSpec(List.copyOf(items), loop); + } + + public Builder loop(boolean v) { + loop = v; + return this; + } + + public Builder loop() { + return loop(true); + } + + public final class ItemBuilder { + + private ItemBuilder(CommandMockSpec mockSpec) { + this.mockSpec = Objects.requireNonNull(mockSpec); + } + + public Builder add() { + items.add(new Item(mockSpec, repeat, detailedDescription)); + return Builder.this; + } + + public ItemBuilder repeat(int v) { + if (repeat < 0) { + throw new IllegalArgumentException(); + } + repeat = v; + return this; + } + + public ItemBuilder detailedDescription(boolean v) { + detailedDescription = v; + return this; + } + + public ItemBuilder detailedDescription() { + return detailedDescription(true); + } + + private final CommandMockSpec mockSpec; + private int repeat; + private boolean detailedDescription; + } + + public Builder add(CommandMockSpec mockSpec) { + return build(mockSpec).add(); + } + + public Builder addLoop(CommandMockSpec mockSpec) { + return build(mockSpec).add(); + } + + public ItemBuilder build(CommandMockSpec mockSpec) { + return new ItemBuilder(mockSpec); + } + + private final List items = new ArrayList<>(); + private boolean loop; + } +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ScriptSpecInDir.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ScriptSpecInDir.java new file mode 100644 index 00000000000..8c59c777d96 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ScriptSpecInDir.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.nio.file.Path; +import java.util.Objects; + +/** + * Specification of a {@link Script} bound to a specific directory. + */ +public class ScriptSpecInDir { + + public ScriptSpecInDir() { + } + + @Override + public String toString() { + return scriptSpec.toString(); + } + + public boolean isPathInDir(Path path) { + return path.startsWith(dir); + } + + public ScriptSpecInDir dir(Path v) { + dir = v; + return this; + } + + public ScriptSpecInDir scriptSpec(ScriptSpec v) { + scriptSpec = v; + return this; + } + + public ScriptSpec scriptSpec() { + Objects.requireNonNull(dir); + return Objects.requireNonNull(scriptSpec); + } + + public Script create() { + return scriptSpec().create(); + } + + private ScriptSpec scriptSpec; + private Path dir; +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ToolProviderCommandMock.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ToolProviderCommandMock.java new file mode 100644 index 00000000000..ee9556277d4 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ToolProviderCommandMock.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +import java.util.spi.ToolProvider; + +public sealed interface ToolProviderCommandMock extends CommandMock, ToolProvider + permits ToolProviderCompletableCommandMock, MockingToolProvider { +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ToolProviderCompletableCommandMock.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ToolProviderCompletableCommandMock.java new file mode 100644 index 00000000000..907cedd38d7 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/ToolProviderCompletableCommandMock.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +public sealed interface ToolProviderCompletableCommandMock extends ToolProviderCommandMock, CompletableCommandMock + permits MockingToolProvider.Completable { +} diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/VerbatimCommandMock.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/VerbatimCommandMock.java new file mode 100644 index 00000000000..799caaa0ea9 --- /dev/null +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/mock/VerbatimCommandMock.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.test.mock; + +public enum VerbatimCommandMock implements CommandMock { + + INSTANCE +} diff --git a/test/jdk/tools/jpackage/junit/linux/jdk.jpackage/jdk/jpackage/internal/LibProvidersLookupTest.java b/test/jdk/tools/jpackage/junit/linux/jdk.jpackage/jdk/jpackage/internal/LibProvidersLookupTest.java new file mode 100644 index 00000000000..41a9bdc647e --- /dev/null +++ b/test/jdk/tools/jpackage/junit/linux/jdk.jpackage/jdk/jpackage/internal/LibProvidersLookupTest.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.jpackage.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jdk.jpackage.test.mock.CommandActionSpecs; +import jdk.jpackage.test.mock.CommandMockExit; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +public class LibProvidersLookupTest { + + @ParameterizedTest + @EnumSource(value = CommandMockExit.class) + public void test_supported(CommandMockExit exit) { + + var ldd = CommandActionSpecs.build().exit(exit).toCommandMockBuilder().name("ldd-mock").create(); + + Globals.main(() -> { + Globals.instance().executorFactory(() -> { + return new Executor().mapper(executor -> { + return executor.copy().mapper(null).toolProvider(ldd); + }); + }); + + boolean actual = LibProvidersLookup.supported(); + assertEquals(exit.exitNormally(), actual); + + return 0; + }); + } +} diff --git a/test/jdk/tools/jpackage/junit/linux/jdk.jpackage/jdk/jpackage/internal/LinuxPackageArchTest.java b/test/jdk/tools/jpackage/junit/linux/jdk.jpackage/jdk/jpackage/internal/LinuxPackageArchTest.java new file mode 100644 index 00000000000..baf03a32142 --- /dev/null +++ b/test/jdk/tools/jpackage/junit/linux/jdk.jpackage/jdk/jpackage/internal/LinuxPackageArchTest.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.jpackage.internal; + +import static jdk.jpackage.internal.model.StandardPackageType.LINUX_DEB; +import static jdk.jpackage.internal.model.StandardPackageType.LINUX_RPM; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import jdk.jpackage.internal.model.StandardPackageType; +import jdk.jpackage.internal.util.Result; +import jdk.jpackage.test.mock.CommandActionSpecs; +import jdk.jpackage.test.mock.CommandMockExit; +import jdk.jpackage.test.mock.CommandMockSpec; +import jdk.jpackage.test.mock.Script; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class LinuxPackageArchTest { + + @ParameterizedTest + @MethodSource + public void test(Runnable test) { + test.run(); + } + + private static List test() { + var data = new ArrayList(); + + // "foo" stdout interleaved with "bar" stderr + var fooArch = CommandActionSpecs.build() + .printToStdout("f").printToStderr("b") + .printToStdout("o").printToStderr("a") + .printToStdout("o").printToStderr("r"); + + for (var exit : CommandMockExit.values()) { + var dpkg = fooArch.copy().printToStdout("-deb").exit(exit).create(); + + data.add(new DebTestSpec(dpkg, Optional.of("foo-deb").filter(_ -> { + return exit.succeed(); + }))); + } + + for (var rpmbuildExit : CommandMockExit.values()) { + var rpmbuild = fooArch.copy().printToStdout("-rpmbuild").exit(rpmbuildExit).create(); + for (var rpmExit : CommandMockExit.values()) { + var rpm = fooArch.copy().printToStdout("-rpm").exit(rpmExit).create(); + Optional expect; + if (rpmbuildExit.succeed()) { + expect = Optional.of("foo-rpmbuild"); + rpm = CommandActionSpecs.UNREACHABLE; + } else { + if (rpmExit.succeed()) { + expect = Optional.of("foo-rpm"); + } else { + expect = Optional.empty(); + } + } + + data.add(new RpmTestSpec(rpmbuild, rpm, expect)); + } + } + + return data; + } + + record RpmTestSpec(CommandActionSpecs rpmbuild, CommandActionSpecs rpm, Optional expect) implements Runnable { + + RpmTestSpec { + Objects.requireNonNull(rpm); + Objects.requireNonNull(rpmbuild); + Objects.requireNonNull(expect); + } + + @Override + public void run() { + + // Create an executor factory that will: + // - Substitute the "rpm" command with `rpm` mock. + // - Substitute the "rpmbuild" command with `rpmbuild` mock. + // - Throw if a command with the name other than "rpm" and "rpmbuild" is requested for execution. + + var script = Script.build() + // LinuxPackageArch must run the "rpmbuild" command first. Put its mapping at the first position. + .map(new CommandMockSpec("rpmbuild", rpmbuild)) + // LinuxPackageArch may optionally run the "rpm" command. Put its mapping after the "rpmbuild" command mapping. + .map(new CommandMockSpec("rpm", rpm)) + // Create a sequential script: after every Script#map() call, the script will advance the current mapping. + // This means each mapping in the script will be considered only once. + // If "rpm" and "rpmbuild" commands are executed in reverse order, the second Script#map() will throw. + .createSequence(); + + test(expect, LINUX_RPM, script); + } + } + + record DebTestSpec(CommandActionSpecs dpkg, Optional expect) implements Runnable { + + DebTestSpec { + Objects.requireNonNull(dpkg); + Objects.requireNonNull(expect); + } + + @Override + public void run() { + var script = Script.build().map(new CommandMockSpec("dpkg", dpkg)).createSequence(); + + test(expect, LINUX_DEB, script); + } + } + + private static void test(Optional expectedArch, StandardPackageType pkgType, Script script) { + + Globals.main(() -> { + + MockUtils.buildJPackage().script(script).applyToGlobals(); + + Result arch = LinuxPackageArch.create(pkgType); + + assertEquals(arch.hasValue(), expectedArch.isPresent()); + expectedArch.ifPresent(v -> { + assertEquals(v, arch.orElseThrow().value()); + }); + + assertEquals(List.of(), script.incompleteMocks()); + + return 0; + }); + } +} diff --git a/test/jdk/tools/jpackage/junit/linux/jdk.jpackage/jdk/jpackage/internal/LinuxSystemEnvironmentTest.java b/test/jdk/tools/jpackage/junit/linux/jdk.jpackage/jdk/jpackage/internal/LinuxSystemEnvironmentTest.java new file mode 100644 index 00000000000..8ff958491b1 --- /dev/null +++ b/test/jdk/tools/jpackage/junit/linux/jdk.jpackage/jdk/jpackage/internal/LinuxSystemEnvironmentTest.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.jpackage.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import jdk.jpackage.internal.model.StandardPackageType; +import jdk.jpackage.test.mock.CommandActionSpecs; +import jdk.jpackage.test.mock.CommandMockExit; +import jdk.jpackage.test.mock.CommandMockSpec; +import jdk.jpackage.test.mock.Script; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class LinuxSystemEnvironmentTest { + + @ParameterizedTest + @MethodSource + public void test_detectNativePackageType(DetectNativePackageTypeTestSpec test) { + test.run(); + } + + private static List test_detectNativePackageType() { + var data = new ArrayList(); + for (var rpmExit : CommandMockExit.values()) { + for (var debExit : CommandMockExit.values()) { + CommandActionSpecs deb = CommandActionSpecs.build().exit(debExit).create(); + CommandActionSpecs rpm; + Optional expected; + if (debExit.succeed()) { + expected = Optional.of(StandardPackageType.LINUX_DEB); + rpm = CommandActionSpecs.UNREACHABLE; + } else { + rpm = CommandActionSpecs.build().exit(rpmExit).create(); + if (rpmExit.succeed()) { + expected = Optional.of(StandardPackageType.LINUX_RPM); + } else { + expected = Optional.empty(); + } + } + data.add(new DetectNativePackageTypeTestSpec(expected, rpm, deb)); + } + } + return data; + } + + record DetectNativePackageTypeTestSpec(Optional expect, CommandActionSpecs rpm, CommandActionSpecs deb) { + + DetectNativePackageTypeTestSpec { + Objects.requireNonNull(expect); + Objects.requireNonNull(rpm); + Objects.requireNonNull(deb); + } + + void run() { + + var script = Script.build() + .map(new CommandMockSpec("rpm", rpm)) + .map(new CommandMockSpec("dpkg", deb)) + .createLoop(); + + Globals.main(() -> { + + MockUtils.buildJPackage().script(script).applyToGlobals(); + + var actual = LinuxSystemEnvironment.detectNativePackageType(); + + assertEquals(expect, actual); + + assertEquals(List.of(), script.incompleteMocks()); + + return 0; + }); + } + } +} diff --git a/test/jdk/tools/jpackage/junit/linux/junit.java b/test/jdk/tools/jpackage/junit/linux/junit.java index 214812e951e..0fd337c812c 100644 --- a/test/jdk/tools/jpackage/junit/linux/junit.java +++ b/test/jdk/tools/jpackage/junit/linux/junit.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -30,3 +30,35 @@ * ../../share/jdk.jpackage/jdk/jpackage/internal/model/ApplicationLayoutTest.java * @run junit jdk.jpackage/jdk.jpackage.internal.LinuxApplicationLayoutTest */ + +/* @test + * @summary Test LinuxSystemEnvironment + * @requires (os.family == "linux") + * @library /test/jdk/tools/jpackage/helpers + * @build jdk.jpackage.test.mock.* + * @compile/module=jdk.jpackage -Xlint:all -Werror + * jdk/jpackage/internal/LinuxSystemEnvironmentTest.java + * ../../share/jdk.jpackage/jdk/jpackage/internal/MockUtils.java + * @run junit jdk.jpackage/jdk.jpackage.internal.LinuxSystemEnvironmentTest + */ + +/* @test + * @summary Test LibProvidersLookup + * @requires (os.family == "linux") + * @library /test/jdk/tools/jpackage/helpers + * @build jdk.jpackage.test.mock.* + * @compile/module=jdk.jpackage -Xlint:all -Werror + * jdk/jpackage/internal/LibProvidersLookupTest.java + * @run junit jdk.jpackage/jdk.jpackage.internal.LibProvidersLookupTest + */ + +/* @test + * @summary Test LinuxPackageArch + * @requires (os.family == "linux") + * @library /test/jdk/tools/jpackage/helpers + * @build jdk.jpackage.test.mock.* + * @compile/module=jdk.jpackage -Xlint:all -Werror + * jdk/jpackage/internal/LinuxPackageArchTest.java + * ../../share/jdk.jpackage/jdk/jpackage/internal/MockUtils.java + * @run junit jdk.jpackage/jdk.jpackage.internal.LinuxPackageArchTest + */ diff --git a/test/jdk/tools/jpackage/junit/macosx/jdk.jpackage/jdk/jpackage/internal/MacDmgPackagerTest.java b/test/jdk/tools/jpackage/junit/macosx/jdk.jpackage/jdk/jpackage/internal/MacDmgPackagerTest.java new file mode 100644 index 00000000000..e5da383142a --- /dev/null +++ b/test/jdk/tools/jpackage/junit/macosx/jdk.jpackage/jdk/jpackage/internal/MacDmgPackagerTest.java @@ -0,0 +1,420 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.jpackage.internal; + +import static jdk.jpackage.internal.model.StandardPackageType.MAC_DMG; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import jdk.internal.util.OperatingSystem; +import jdk.jpackage.internal.MacPackagingPipeline.MacBuildApplicationTaskID; +import jdk.jpackage.internal.PackagingPipeline.BuildApplicationTaskID; +import jdk.jpackage.internal.model.AppImageLayout; +import jdk.jpackage.internal.model.RuntimeBuilder; +import jdk.jpackage.internal.util.CommandOutputControl.UnexpectedResultException; +import jdk.jpackage.internal.util.FileUtils; +import jdk.jpackage.internal.util.RetryExecutor; +import jdk.jpackage.internal.util.function.ExceptionBox; +import jdk.jpackage.test.mock.CommandActionSpec; +import jdk.jpackage.test.mock.CommandActionSpecs; +import jdk.jpackage.test.mock.CommandMockSpec; +import jdk.jpackage.test.mock.MockIllegalStateException; +import jdk.jpackage.test.mock.ScriptSpec; +import jdk.jpackage.test.mock.ScriptSpecInDir; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class MacDmgPackagerTest { + + /** + * Exercise branches in {@link MacDmgPackager#buildDMG()}. + */ + @ParameterizedTest + @MethodSource + public void test(DmgScript scriptSpec, @TempDir Path workDir) { + scriptSpec.run(workDir); + } + + private static List test() { + var data = new ArrayList(); + + var succeed = CommandActionSpecs.build().exit().create(); + var fail = CommandActionSpecs.build().exit(1).create(); + + // Test create + for (var createFullSucceed : List.of(true, false)) { + var dmgScript = new DmgScript(); + + var scriptBuilder = ScriptSpec.build(); + + if (createFullSucceed) { + // `hdiutil create -srcfolder` succeeds + scriptBuilder.add(new CommandMockSpec("hdiutil", "hdiutil-create", dmgScript.hdiutilCreate().exit().create())); + } else { + // `hdiutil create -srcfolder` fails + scriptBuilder.add(new CommandMockSpec("hdiutil", "hdiutil-create", fail)); + scriptBuilder.add(new CommandMockSpec("hdiutil", "hdiutil-create-empty", dmgScript.hdiutilCreateEmpty().exit().create())); + } + + scriptBuilder + // `hdiutil attach` succeeds + .add(new CommandMockSpec("hdiutil", "hdiutil-attach", succeed)) + // `osascript` succeeds + .add(new CommandMockSpec("osascript", succeed)) + // `hdiutil detach` succeeds + .add(new CommandMockSpec("hdiutil", "hdiutil-detach", dmgScript.hdiutilDetach().exit().create())) + // `hdiutil convert` succeeds + .add(new CommandMockSpec("hdiutil", "hdiutil-convert", dmgScript.hdiutilConvert().exit().create())); + + data.add(dmgScript.scriptSpec(scriptBuilder.create())); + } + + // Test detach + for (var detachResult : DetachResult.values()) { + var dmgScript = new DmgScript(); + + var scriptBuilder = ScriptSpec.build() + .add(new CommandMockSpec("hdiutil", "hdiutil-create", dmgScript.hdiutilCreate().exit().create())) + .add(new CommandMockSpec("hdiutil", "hdiutil-attach", succeed)) + .add(new CommandMockSpec("osascript", succeed)); + + switch (detachResult) { + case ALL_FAIL -> { + dmgScript.expect(UnexpectedResultException.class); + scriptBuilder.build(new CommandMockSpec("hdiutil", "hdiutil-detach", fail)).repeat(9).add(); + } + case LAST_SUCCEED -> { + scriptBuilder + .build(new CommandMockSpec("hdiutil", "hdiutil-detach", fail)).repeat(8).add() + .add(new CommandMockSpec("hdiutil", "hdiutil-detach", dmgScript.hdiutilDetach().exit().create())) + .add(new CommandMockSpec("hdiutil", "hdiutil-convert", dmgScript.hdiutilConvert().exit().create())); + } + case FIRST_SUCCEED_WITH_EXIT_1 -> { + scriptBuilder + .build(new CommandMockSpec("hdiutil", "hdiutil-detach", dmgScript.hdiutilDetach().exit(1).create())) + .detailedDescription().add() + .add(new CommandMockSpec("hdiutil", "hdiutil-convert", dmgScript.hdiutilConvert().exit().create())); + } + case FIRST_SUCCEED_MOUNT_POINT_REMAINS -> { + scriptBuilder + .build(new CommandMockSpec("hdiutil", "hdiutil-detach", dmgScript.hdiutilDetach(false).exit().create())) + .detailedDescription().add() + .add(new CommandMockSpec("hdiutil", "hdiutil-convert", dmgScript.hdiutilConvert().exit().create())); + } + } + + data.add(dmgScript.scriptSpec(scriptBuilder.create())); + } + + return data; + } + + private enum DetachResult { + ALL_FAIL, + LAST_SUCCEED, + // The first `hdiutil detach` attempt exits with exit code "1" but deletes the mounted directory. + FIRST_SUCCEED_WITH_EXIT_1, + // The first `hdiutil detach` attempt exits with exit code "0" and the mounted directory stays undeleted. + FIRST_SUCCEED_MOUNT_POINT_REMAINS, + ; + } + + private static MacDmgSystemEnvironment createSysEnv(ScriptSpec scriptSpec) { + return new MacDmgSystemEnvironment( + Path.of("hdiutil"), + Path.of("osascript"), + Stream.of("SetFile").map(Path::of).filter(scriptSpec.commandNames()::contains).findFirst() + ); + } + + private static RuntimeBuilder createRuntimeBuilder() { + return new RuntimeBuilder() { + @Override + public void create(AppImageLayout appImageLayout) { + throw new UnsupportedOperationException(); + } + }; + } + + private static void runPackagingMock(Path workDir, MacDmgSystemEnvironment sysEnv) { + + var app = new ApplicationBuilder() + .appImageLayout(MacPackagingPipeline.APPLICATION_LAYOUT) + .runtimeBuilder(createRuntimeBuilder()) + .name("foo") + .create(); + + var macApp = new MacApplicationBuilder(app).create(); + + var macDmgPkg = new MacDmgPackageBuilder(new MacPackageBuilder(new PackageBuilder(macApp, MAC_DMG))).create(); + + var buildEnv = new BuildEnvBuilder(workDir.resolve("build-root")).appImageDirFor(macDmgPkg).create(); + + var packager = new MacDmgPackager(buildEnv, macDmgPkg, workDir, sysEnv); + + var pipelineBuilder = MacPackagingPipeline.build(Optional.of(packager.pkg())); + packager.accept(pipelineBuilder); + + // Disable actions of tasks filling an application image. + Stream.concat( + Stream.of(BuildApplicationTaskID.values()), + Stream.of(MacBuildApplicationTaskID.values()) + ).forEach(taskId -> { + pipelineBuilder.task(taskId).noaction().add(); + }); + + var contentMock = new ContentMock(); + + // Fill application image with content mock. + pipelineBuilder.task(BuildApplicationTaskID.CONTENT).applicationAction(env -> { + contentMock.create(env.resolvedLayout().contentDirectory()); + }).add(); + + pipelineBuilder.create().execute(buildEnv, packager.pkg(), packager.outputDir()); + + var outputDmg = packager.outputDir().resolve(packager.pkg().packageFileNameWithSuffix()); + + contentMock.verifyStoredInFile(outputDmg); + } + + private static final class DmgScript extends ScriptSpecInDir { + + @Override + public String toString() { + var sb = new StringBuilder(); + sb.append(super.toString()); + Optional.ofNullable(expectedErrorType).ifPresent(type -> { + sb.append("; ").append(type.getCanonicalName()); + }); + return sb.toString(); + } + + @Override + public DmgScript scriptSpec(ScriptSpec v) { + super.scriptSpec(v); + return this; + } + + DmgScript expect(Class v) { + expectedErrorType = v; + return this; + } + + void run(Path workDir) { + + var script = dir(Objects.requireNonNull(workDir)).create(); + + ExecutorFactory executorFactory = MockUtils.buildJPackage() + .script(script).listener(System.out::println).createExecutorFactory(); + + var objectFactory = ObjectFactory.build() + .executorFactory(executorFactory) + .retryExecutorFactory(new RetryExecutorFactory() { + @Override + public RetryExecutor retryExecutor(Class exceptionType) { + return RetryExecutorFactory.DEFAULT.retryExecutor(exceptionType).setSleepFunction(_ -> { + // Don't "sleep" to make the test run faster. + }); + } + }) + .create(); + + Globals.main(() -> { + Globals.instance().objectFactory(objectFactory); + if (expectedErrorType == null) { + runPackagingMock(workDir, createSysEnv(scriptSpec())); + } else { + var ex = assertThrows(Exception.class, () -> { + runPackagingMock(workDir, createSysEnv(scriptSpec())); + }); + var cause = ExceptionBox.unbox(ex); + assertEquals(expectedErrorType, cause.getClass()); + } + return 0; + }); + + assertEquals(List.of(), script.incompleteMocks()); + } + + CommandActionSpecs.Builder hdiutilCreate(boolean empty) { + CommandActionSpec action = CommandActionSpec.create("create", context -> { + var dstDmg = Path.of(context.optionValue("-ov")); + assertTrue(isPathInDir(dstDmg)); + + var volumeName = context.optionValue("-volname"); + + if (empty) { + createDmg(new CreateDmgResult(dstDmg, volumeName, Optional.empty())); + Files.createFile(dstDmg); + } else { + var srcDir = Path.of(context.optionValue("-srcfolder")); + assertTrue(isPathInDir(srcDir)); + + createDmg(new CreateDmgResult(dstDmg, volumeName, Optional.of(srcDir))); + + try (var walk = Files.walk(srcDir)) { + var paths = walk.map(srcDir::relativize).map(Path::toString).toList(); + Files.write(dstDmg, paths); + } + } + }); + return CommandActionSpecs.build().action(action); + } + + CommandActionSpecs.Builder hdiutilCreate() { + return hdiutilCreate(false); + } + + CommandActionSpecs.Builder hdiutilCreateEmpty() { + return hdiutilCreate(true); + } + + CommandActionSpecs.Builder hdiutilDetach() { + return hdiutilDetach(true); + } + + CommandActionSpecs.Builder hdiutilDetach(boolean deleteMountPoint) { + var sb = new StringBuilder(); + sb.append("detach"); + if (!deleteMountPoint) { + sb.append("(rm-mount-point)"); + } + CommandActionSpec action = CommandActionSpec.create(sb.toString(), context -> { + var mountPoint = Path.of(context.args().getLast()); + assertTrue(isPathInDir(mountPoint)); + + try (var walk = Files.walk(mountPoint)) { + var dstDmg = dmg().dmg(); + var paths = Stream.concat( + walk.map(mountPoint::relativize), + Files.readAllLines(dstDmg).stream().filter(Predicate.not(String::isEmpty)).map(Path::of) + ).sorted().map(Path::toString).toList(); + Files.write(dstDmg, paths); + } + + if (deleteMountPoint) { + FileUtils.deleteRecursive(mountPoint); + } + }); + return CommandActionSpecs.build().action(action); + } + + CommandActionSpecs.Builder hdiutilConvert() { + CommandActionSpec action = CommandActionSpec.create("convert", context -> { + var srcDmg = Path.of(context.args().get(1)); + assertTrue(isPathInDir(srcDmg)); + + var dstDmg = Path.of(context.args().getLast()); + assertTrue(isPathInDir(dstDmg)); + + Files.copy(srcDmg, dstDmg); + }); + return CommandActionSpecs.build().action(action); + } + + private void createDmg(CreateDmgResult v) { + if (dmg != null) { + throw new MockIllegalStateException("The DMG already set"); + } else { + dmg = Objects.requireNonNull(v); + } + } + + private CreateDmgResult dmg() { + if (dmg == null) { + throw new MockIllegalStateException("The DMG not set"); + } else { + return dmg; + } + } + + private record CreateDmgResult(Path dmg, String volumeName, Optional srcFolder) { + CreateDmgResult { + Objects.requireNonNull(dmg); + Objects.requireNonNull(volumeName); + Objects.requireNonNull(srcFolder); + } + } + + private CreateDmgResult dmg; + private Class expectedErrorType; + } + + private static final class ContentMock { + + void create(Path dir) throws IOException { + Files.createDirectories(dir.resolve("foo/bar")); + Files.writeString(dir.resolve("foo/bar/buz"), "Hello!"); + if (!OperatingSystem.isWindows()) { + Files.createSymbolicLink(dir.resolve("symlink"), Path.of("foo")); + } + } + + void verifyStoredInFile(Path file) { + try { + var expectedPaths = Stream.of( + Stream.of(Path.of("")), + DMG_ICON_FILES.stream(), + Stream.of( + Stream.of("foo/bar/buz"), + Stream.of("symlink").filter(_ -> { + return !OperatingSystem.isWindows(); + }) + ).flatMap(x -> x).map(Path::of).map(Path.of("foo.app/Contents")::resolve) + ).flatMap(x -> x).mapMulti(EXPAND_PATH).sorted().distinct().toList(); + var actualPaths = Files.readAllLines(file).stream().map(Path::of).toList(); + assertEquals(expectedPaths, actualPaths); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + } + + private final static BiConsumer> EXPAND_PATH = (path, sink) -> { + do { + sink.accept(path); + path = path.getParent(); + } while (path != null); + }; + + private final static List DMG_ICON_FILES = Stream.of( + ".VolumeIcon.icns", + ".background/background.tiff" + ).map(Path::of).collect(Collectors.toUnmodifiableList()); +} diff --git a/test/jdk/tools/jpackage/junit/macosx/jdk.jpackage/jdk/jpackage/internal/MacDmgSystemEnvironmentTest.java b/test/jdk/tools/jpackage/junit/macosx/jdk.jpackage/jdk/jpackage/internal/MacDmgSystemEnvironmentTest.java new file mode 100644 index 00000000000..de2b07e86a6 --- /dev/null +++ b/test/jdk/tools/jpackage/junit/macosx/jdk.jpackage/jdk/jpackage/internal/MacDmgSystemEnvironmentTest.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.jpackage.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import jdk.jpackage.internal.util.RetryExecutor; +import jdk.jpackage.test.mock.CommandActionSpecs; +import jdk.jpackage.test.mock.CommandMockExit; +import jdk.jpackage.test.mock.CommandMockSpec; +import jdk.jpackage.test.mock.Script; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class MacDmgSystemEnvironmentTest { + + @ParameterizedTest + @MethodSource + void test_findSetFileUtility(FindSetFileUtilityTestSpec test) { + test.run(); + } + + private static List test_findSetFileUtility() { + var data = new ArrayList(); + + var succeed = CommandActionSpecs.build().exit().create(); + + for (var failureCause : List.of(CommandMockExit.EXIT_1, CommandMockExit.THROW_MOCK_IO_EXCEPTION)) { + + var fail = CommandActionSpecs.build().exit(failureCause).create(); + + for (var i = 0; i != MacDmgSystemEnvironment.SETFILE_KNOWN_PATHS.size(); i++) { + + var expected = MacDmgSystemEnvironment.SETFILE_KNOWN_PATHS.get(i); + + var mocks = new ArrayList(); + + MacDmgSystemEnvironment.SETFILE_KNOWN_PATHS.subList(0, i).stream().map(failureSetFilePath -> { + return new CommandMockSpec(failureSetFilePath, fail); + }).forEach(mocks::add); + + mocks.add(new CommandMockSpec(expected, succeed)); + + data.add(new FindSetFileUtilityTestSpec(Optional.of(expected), mocks)); + } + + var lastMocks = data.getLast().mockSpecs(); + var lastSucceedMock = lastMocks.getLast(); + var lastFailMock = new CommandMockSpec(lastSucceedMock.name(), lastSucceedMock.mockName(), fail); + + var mocks = new ArrayList<>(lastMocks); + mocks.set(mocks.size() - 1, lastFailMock); + + for (var xcrunOutout : List., Boolean>>of( + // Use the path to the command of the current process + // as an output mock for the /usr/bin/xcrun command. + // MacDmgSystemEnvironment.findSetFileUtility() reads the command output + // and checks whether it is an executable file, + // so the hardcoded value is not an option for the output mock. + Map.entry(Optional.of(ProcessHandle.current().info().command().orElseThrow()), true), + // "/usr/bin/xcrun" outputs a path to non-executable file. + Map.entry(Optional.of("/dev/null"), false), + // "/usr/bin/xcrun" outputs '\0' making subsequent Path.of("\0") fail. + Map.entry(Optional.of("\0"), false), + // "/usr/bin/xcrun" doesn't output anything. + Map.entry(Optional.empty(), false) + )) { + + + mocks.add(new CommandMockSpec("/usr/bin/xcrun", CommandActionSpecs.build().mutate(builder -> { + xcrunOutout.getKey().ifPresent(builder::stdout); + }).exit(CommandMockExit.SUCCEED).create())); + + Optional expected; + if (xcrunOutout.getValue()) { + expected = xcrunOutout.getKey(); + } else { + expected = Optional.empty(); + } + + data.add(new FindSetFileUtilityTestSpec(expected.map(Path::of), List.copyOf(mocks))); + + mocks.removeLast(); + } + + // The last test case: "/usr/bin/xcrun" fails + mocks.add(new CommandMockSpec("/usr/bin/xcrun", fail)); + data.add(new FindSetFileUtilityTestSpec(Optional.empty(), mocks)); + } + + return data; + } + + record FindSetFileUtilityTestSpec(Optional expected, List mockSpecs) { + + FindSetFileUtilityTestSpec { + Objects.requireNonNull(expected); + Objects.requireNonNull(mockSpecs); + } + + @Override + public String toString() { + var tokens = new ArrayList(); + expected.ifPresent(v -> { + tokens.add(String.format("expect=%s", v)); + }); + tokens.add(mockSpecs.toString()); + return tokens.stream().collect(Collectors.joining(", ")); + } + + void run() { + + var script = Script.build().mutate(builder -> { + mockSpecs.forEach(builder::map); + }).createSequence(); + + Globals.main(() -> { + MockUtils.buildJPackage().script(script).applyToGlobals(); + + var actual = MacDmgSystemEnvironment.findSetFileUtility(); + + assertEquals(expected, actual); + assertEquals(List.of(), script.incompleteMocks()); + + return 0; + }); + } + } +} diff --git a/test/jdk/tools/jpackage/junit/macosx/junit.java b/test/jdk/tools/jpackage/junit/macosx/junit.java index 1549aaa6cd7..c7fd2bc5f8d 100644 --- a/test/jdk/tools/jpackage/junit/macosx/junit.java +++ b/test/jdk/tools/jpackage/junit/macosx/junit.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -30,3 +30,25 @@ * ../../share/jdk.jpackage/jdk/jpackage/internal/model/ApplicationLayoutTest.java * @run junit jdk.jpackage/jdk.jpackage.internal.MacApplicationLayoutTest */ + +/* @test + * @summary Test MacDmgSystemEnvironmentTest + * @requires (os.family == "mac") + * @library /test/jdk/tools/jpackage/helpers + * @build jdk.jpackage.test.mock.* + * @compile/module=jdk.jpackage -Xlint:all -Werror + * jdk/jpackage/internal/MacDmgSystemEnvironmentTest.java + * ../../share/jdk.jpackage/jdk/jpackage/internal/MockUtils.java + * @run junit jdk.jpackage/jdk.jpackage.internal.MacDmgSystemEnvironmentTest + */ + +/* @test + * @summary Test MacDmgPackagerTest + * @requires (os.family == "mac") + * @library /test/jdk/tools/jpackage/helpers + * @build jdk.jpackage.test.mock.* + * @compile/module=jdk.jpackage -Xlint:all -Werror + * jdk/jpackage/internal/MacDmgPackagerTest.java + * ../../share/jdk.jpackage/jdk/jpackage/internal/MockUtils.java + * @run junit jdk.jpackage/jdk.jpackage.internal.MacDmgPackagerTest + */ diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/DefaultBundlingEnvironmentTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/DefaultBundlingEnvironmentTest.java index 35ab1fbec5e..1a14330fe6e 100644 --- a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/DefaultBundlingEnvironmentTest.java +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/DefaultBundlingEnvironmentTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2022, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -22,15 +22,42 @@ */ package jdk.jpackage.internal; + +import static jdk.jpackage.internal.util.function.ThrowingSupplier.toSupplier; +import static jdk.jpackage.test.mock.CommandMock.ioerror; +import static jdk.jpackage.test.mock.CommandMock.succeed; import static org.junit.jupiter.api.Assertions.assertEquals; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.spi.ToolProvider; +import java.util.stream.Stream; import jdk.internal.util.OperatingSystem; +import jdk.jpackage.internal.cli.StandardBundlingOperation; +import jdk.jpackage.internal.model.AppImagePackageType; import jdk.jpackage.internal.model.BundlingOperationDescriptor; +import jdk.jpackage.internal.model.PackageType; +import jdk.jpackage.internal.model.StandardPackageType; +import jdk.jpackage.test.Annotations; +import jdk.jpackage.test.HelloApp; +import jdk.jpackage.test.JUnitAdapter; +import jdk.jpackage.test.JavaAppDesc; +import jdk.jpackage.test.TKit; +import jdk.jpackage.test.mock.CommandActionSpecs; +import jdk.jpackage.test.mock.CommandMock; +import jdk.jpackage.test.mock.CommandMockExit; +import jdk.jpackage.test.mock.Script; import org.junit.jupiter.api.Test; -public class DefaultBundlingEnvironmentTest { +public class DefaultBundlingEnvironmentTest extends JUnitAdapter { @Test void testDefaultBundlingOperation() { @@ -55,4 +82,200 @@ public class DefaultBundlingEnvironmentTest { assertEquals(descriptor, env.defaultOperation().orElseThrow()); assertEquals(1, executed[0]); } + + /** + * Tests that commands executed to initialize the system environment are + * executed only once. + * @throws IOException + */ + @Annotations.Test + @Annotations.ParameterSupplier + public void testInitializedOnce(StandardBundlingOperation op) throws IOException { + + List> executedCommands = Collections.synchronizedList(new ArrayList<>()); + + var script = createMockScript(op); + + ToolProvider jpackage = MockUtils.buildJPackage() + .os(op.os()) + .script(script) + .listener(executedCommands::add).create(); + + var inputDir = TKit.createTempDirectory("input"); + var appDesc = JavaAppDesc.parse(null); + HelloApp.createBundle(appDesc, inputDir); + + // + // The command line should fail as the main class name is not specified and it is not set in the main jar. + // + // Run native packaging twice. + // It can execute commands required to configure the system environment in the first iteration. + // It must not execute a single command in the second iteration. + // + // Run app image packaging once. + // It must not execute a single command because app image packaging should not require native commands (Unless + // it is macOS where it will sign the app image with an ad hoc signature + // using the codesign tool. But: #1 - it is not a variable part of the system environment; + // #2 - jpackage should bail out earlier). + // + + final var type = op.packageTypeValue(); + final int iterationCount; + if (op.packageType() instanceof AppImagePackageType) { + iterationCount = 1; + } else { + iterationCount = 2; + } + + for (var i = 0; i != iterationCount; i++) { + var result = new Executor().toolProvider(jpackage).saveOutput().args( + "--type=" + type, + "--input", inputDir.toString(), + "--main-jar", appDesc.jarFileName()).execute(); + + assertEquals(1, result.getExitCode()); + + // Assert it bailed out with the expected error. + assertEquals(List.of( + I18N.format("message.error-header", I18N.format("error.no-main-class-with-main-jar", appDesc.jarFileName())), + I18N.format("message.advice-header", I18N.format("error.no-main-class-with-main-jar.advice", appDesc.jarFileName())) + ), result.stderr()); + + TKit.trace("The list of executed commands:"); + executedCommands.forEach(cmdline -> { + TKit.trace(" " + cmdline); + }); + TKit.trace("Done"); + + if (i == 0) { + executedCommands.clear(); + } + } + + assertEquals(List.of(), executedCommands); + assertEquals(List.of(), script.incompleteMocks()); + } + + public static List testInitializedOnce() { + return StandardBundlingOperation.ofPlatform(OperatingSystem.current()) + .filter(StandardBundlingOperation::isCreateBundle).map(v -> { + return new Object[] {v}; + }).toList(); + } + + private static Script createMockScript(StandardBundlingOperation op) { + + if (op.packageType() instanceof AppImagePackageType) { + return Script.build().createSequence(); + } + + switch (op.os()) { + case WINDOWS -> { + return createWinMockScript(); + } + case LINUX -> { + return createLinuxMockScript(op.packageType()); + } + case MACOS -> { + return createMacMockScript(); + } + default -> { + throw new AssertionError(); + } + } + } + + private static Script createWinMockScript() { + + // Make "candle.exe" and "light.exe" always fail. + var candle = ioerror("candle-mock"); + var light = ioerror("light-mock"); + + // Make the "wix.exe" functional. + var wix = CommandActionSpecs.build() + .stdout("5.0.2+aa65968c") + .exit(CommandMockExit.SUCCEED) + .toCommandMockBuilder().name("wix-mock").create(); + + var script = Script.build() + .map(Script.cmdlineStartsWith("candle.exe"), candle) + .map(Script.cmdlineStartsWith("light.exe"), light) + .map(Script.cmdlineStartsWith("wix.exe"), wix) + .createLoop(); + + return script; + } + + private static Script createMacMockScript() { + + @SuppressWarnings("unchecked") + var setfilePaths = (List)toSupplier(() -> { + return Class.forName(String.join(".", + DefaultBundlingEnvironmentTest.class.getPackageName(), + "MacDmgSystemEnvironment" + )).getDeclaredField("SETFILE_KNOWN_PATHS").get(null); + }).get(); + + var script = Script.build(); + + for (var setfilePath: setfilePaths) { + script.map(Script.cmdlineStartsWith(setfilePath), ioerror(setfilePath.toString() + "-mock")); + } + + script.map(Script.cmdlineStartsWith("/usr/bin/xcrun"), succeed("/usr/bin/xcrun-mock")); + + return script.createLoop(); + } + + private static Script createLinuxMockScript(PackageType pkgType) { + + final Map mocks = new HashMap<>(); + + var script = Script.build(); + + final Set debCommandNames = Set.of("dpkg", "dpkg-deb", "fakeroot"); + final Set rpmCommandNames = Set.of("rpm", "rpmbuild"); + + final Set succeedCommandNames; + switch (pkgType) { + case StandardPackageType.LINUX_DEB -> { + succeedCommandNames = debCommandNames; + // Simulate "dpkg --print-architecture". + var dpkg = CommandActionSpecs.build() + .stdout("foo-arch") + .exit(CommandMockExit.SUCCEED) + .toCommandMockBuilder().name("dpkg-mock").create(); + mocks.put("dpkg", dpkg); + } + case StandardPackageType.LINUX_RPM -> { + succeedCommandNames = rpmCommandNames; + // Simulate "rpmbuild --version" prints the minimal acceptable version. + var rpmbuild = CommandActionSpecs.build() + .stdout("RPM version 4.10") + .exit(CommandMockExit.SUCCEED) + .toCommandMockBuilder().name("rpmbuild-mock").create(); + mocks.put("rpmbuild", rpmbuild); + } + default -> { + throw new IllegalArgumentException(); + } + } + + script.map(Script.cmdlineStartsWith("ldd"), succeed("ldd-mock")); + + for (var commandName : succeedCommandNames) { + if (!mocks.containsKey(commandName)) { + mocks.put(commandName, succeed(commandName + "-mock")); + } + } + + Stream.of(debCommandNames, rpmCommandNames).flatMap(Set::stream).forEach(commandName -> { + var mock = Optional.ofNullable(mocks.get(commandName)).orElseGet(() -> { + return ioerror(commandName + "-mock"); + }); + script.map(Script.cmdlineStartsWith(commandName), mock); + }); + + return script.createLoop(); + } } diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/ExecutorTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/ExecutorTest.java new file mode 100644 index 00000000000..551ef15e991 --- /dev/null +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/ExecutorTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.jpackage.internal; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.stream.Stream; +import jdk.jpackage.internal.util.CommandOutputControl.UnexpectedResultException; +import jdk.jpackage.test.mock.CommandActionSpecs; +import jdk.jpackage.test.mock.CommandMockExit; +import jdk.jpackage.test.mock.CompletableCommandMock; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class ExecutorTest { + + @ParameterizedTest + @MethodSource + public void test_retryOnKnownErrorMessage(RetryOnKnownErrorMessageTestSpec test) { + test.run(); + } + + private static Stream test_retryOnKnownErrorMessage() { + var data = new ArrayList(); + + final var subject = "French fries"; + + Supplier build = () -> { + return RetryOnKnownErrorMessageTestSpec.build().subject(subject); + }; + + for (var exit : Stream.of(CommandMockExit.values()).filter(CommandMockExit::exitNormally).toList()) { + // These should succeed as there is no "French fries" in stderr. + Stream.of( + build.get().mock(CommandActionSpecs.build().stderr("Coleslaw").exit(exit)), + build.get().mock(CommandActionSpecs.build().stdout(subject).exit(exit)), + build.get() + // Fail in the first attempt (triggering text in the stderr) + .mock(CommandActionSpecs.build().stderr(subject).exit()) + // Fail in the second attempt (same reason) + .repeatLastMoc() + // Pass in the next attempt (no triggering text in the stderr) + .mock(CommandActionSpecs.build().stderr("Coleslaw").exit(exit)), + build.get() + // Fail in the first attempt (triggering text in the stderr) + .mock(CommandActionSpecs.build().stderr(subject)) + // Fail in the second attempt (error running the command) + .mock(CommandActionSpecs.build().exit(CommandMockExit.THROW_MOCK_IO_EXCEPTION)) + // Pass in the next attempt (no triggering text in the stderr) + .mock(CommandActionSpecs.build().exit(exit)) + ).map(RetryOnKnownErrorMessageTestSpec.Builder::success).forEach(data::add); + } + + // These should fail as there is "French fries" in stderr. + data.addAll(List.of( + // Try once and fail. + build.get().mock(CommandActionSpecs.build().stderr(subject).exit()), + // Try twice and fail. + build.get().mock(CommandActionSpecs.build().stderr(subject).exit()).repeatLastMoc() + )); + + return data.stream().map(RetryOnKnownErrorMessageTestSpec.Builder::create); + } + + record RetryOnKnownErrorMessageTestSpec(List mockSpecs, String subject, boolean success) { + + RetryOnKnownErrorMessageTestSpec { + Objects.requireNonNull(mockSpecs); + Objects.requireNonNull(subject); + + if (mockSpecs.isEmpty()) { + throw new IllegalArgumentException(); + } + } + + void run() { + var mock = mockSpecs.stream() + .reduce(CommandActionSpecs::andThen) + .orElseThrow().toCommandMockBuilder() + // Ensure attempts to run the command more times than expected will fail. + .noRepeats().create(); + + var retry = new Executor().toolProvider(mock).retryOnKnownErrorMessage(subject) + .setAttemptTimeout(null) + .setMaxAttemptsCount(mockSpecs.size()); + + if (success) { + assertDoesNotThrow(retry::execute); + } else { + assertThrowsExactly(UnexpectedResultException.class, retry::execute); + } + + assertTrue(((CompletableCommandMock)mock).completed()); + } + + static Builder build() { + return new Builder(); + } + + static final class Builder { + + RetryOnKnownErrorMessageTestSpec create() { + return new RetryOnKnownErrorMessageTestSpec(mockSpecs, subject, success); + } + + public Builder mock(CommandActionSpecs v) { + mockSpecs.add(Objects.requireNonNull(v)); + return this; + } + + public Builder mock(CommandActionSpecs.Builder v) { + return mock(v.create()); + } + + public Builder repeatLastMoc() { + return mock(mockSpecs.getLast()); + } + + public Builder subject(String v) { + subject = v; + return this; + } + + public Builder success(boolean v) { + success = v; + return this; + } + + public Builder success() { + return success(true); + } + + private final List mockSpecs = new ArrayList<>(); + private String subject; + private boolean success; + } + } +} diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/MockUtils.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/MockUtils.java new file mode 100644 index 00000000000..e88077a6c9d --- /dev/null +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/MockUtils.java @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.jpackage.internal; + +import static jdk.jpackage.internal.util.function.ThrowingSupplier.toSupplier; + +import java.io.PrintWriter; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; +import java.util.spi.ToolProvider; +import jdk.internal.util.OperatingSystem; +import jdk.jpackage.internal.cli.CliBundlingEnvironment; +import jdk.jpackage.internal.cli.Main; +import jdk.jpackage.internal.util.function.ExceptionBox; +import jdk.jpackage.test.mock.Script; +import jdk.jpackage.test.mock.ToolProviderCommandMock; +import jdk.jpackage.test.mock.VerbatimCommandMock; + +/** + * Bridges "jdk.jpackage.internal" and "jdk.jpackage.test.mock" packages. + */ +public final class MockUtils { + + private MockUtils() { + } + + public static JPackageToolProviderBuilder buildJPackage() { + return new JPackageToolProviderBuilder(); + } + + public static final class JPackageToolProviderBuilder { + + public ToolProvider create() { + return createJPackageToolProvider(os(), createObjectFactory()); + } + + public Consumer createGlobalsMutator() { + var objectFactory = createObjectFactory(); + return globals -> { + globals.objectFactory(objectFactory); + }; + } + + public void applyToGlobals() { + createGlobalsMutator().accept(Globals.instance()); + } + + ExecutorFactory createExecutorFactory() { + var commandMocksExecutorFactory = Optional.ofNullable(script).map(MockUtils::withCommandMocks).map(mapper -> { + return mapper.apply(ExecutorFactory.DEFAULT); + }).orElse(ExecutorFactory.DEFAULT); + + var recordingExecutorFactory = Optional.ofNullable(listener).map(MockUtils::withCommandListener).map(mapper -> { + return mapper.apply(commandMocksExecutorFactory); + }).orElse(commandMocksExecutorFactory); + + return recordingExecutorFactory; + } + + ObjectFactory createObjectFactory() { + var executorFactory = createExecutorFactory(); + if (executorFactory == ExecutorFactory.DEFAULT) { + return ObjectFactory.DEFAULT; + } else { + return ObjectFactory.build().executorFactory(executorFactory).create(); + } + } + + public JPackageToolProviderBuilder listener(Consumer> v) { + listener = v; + return this; + } + + public JPackageToolProviderBuilder script(Script v) { + script = v; + return this; + } + + public JPackageToolProviderBuilder os(OperatingSystem v) { + os = v; + return this; + } + + private OperatingSystem os() { + return Optional.ofNullable(os).orElseGet(OperatingSystem::current); + } + + private Consumer> listener; + private OperatingSystem os; + private Script script; + } + + public static ToolProvider createJPackageToolProvider(OperatingSystem os, Script script) { + return buildJPackage() + .os(Objects.requireNonNull(os)) + .script(Objects.requireNonNull(script)) + .create(); + } + + public static ToolProvider createJPackageToolProvider(Script script) { + return createJPackageToolProvider(OperatingSystem.current(), script); + } + + private static UnaryOperator withCommandListener(Consumer> listener) { + Objects.requireNonNull(listener); + return executorFactory -> { + Objects.requireNonNull(executorFactory); + return () -> { + var executor = executorFactory.executor(); + + Optional> oldMapper = executor.mapper(); + + UnaryOperator newMapper = exec -> { + listener.accept(exec.commandLine()); + return exec; + }; + + return executor.mapper(oldMapper.map(newMapper::compose).orElse(newMapper)::apply); + }; + }; + } + + private static UnaryOperator withCommandMocks(Script script) { + return executorFactory -> { + Objects.requireNonNull(executorFactory); + return () -> { + var executor = executorFactory.executor(); + + Optional> oldMapper = executor.mapper(); + + UnaryOperator newMapper = exec -> { + var commandLine = exec.commandLine(); + var mock = Objects.requireNonNull(script.map(commandLine)); + switch (mock) { + case VerbatimCommandMock.INSTANCE -> { + // No mock for this command line. + return exec; + } + case ToolProviderCommandMock tp -> { + // Create a copy of the executor with the old mapper to prevent further recursion. + var copy = exec.copy().mapper(oldMapper.orElse(null)); + copy.toolProvider(tp); + copy.args().clear(); + copy.args(commandLine.subList(1, commandLine.size())); + return copy; + } + default -> { + // Unreachable because there are no other cases for this switch. + throw ExceptionBox.reachedUnreachable(); + } + } + }; + + return executor.mapper(oldMapper.map(newMapper::compose).orElse(newMapper)::apply); + }; + }; + } + + public static CliBundlingEnvironment createBundlingEnvironment(OperatingSystem os) { + Objects.requireNonNull(os); + + String bundlingEnvironmentClassName; + switch (os) { + case WINDOWS -> { + bundlingEnvironmentClassName = "WinBundlingEnvironment"; + } + case LINUX -> { + bundlingEnvironmentClassName = "LinuxBundlingEnvironment"; + } + case MACOS -> { + bundlingEnvironmentClassName = "MacBundlingEnvironment"; + } + default -> { + throw new IllegalArgumentException(); + } + } + + return toSupplier(() -> { + var ctor = Class.forName(String.join(".", + DefaultBundlingEnvironment.class.getPackageName(), + bundlingEnvironmentClassName + )).getConstructor(); + return (CliBundlingEnvironment)ctor.newInstance(); + }).get(); + } + + static ToolProvider createJPackageToolProvider(OperatingSystem os, ObjectFactory of) { + Objects.requireNonNull(os); + Objects.requireNonNull(of); + + var impl = new Main.Provider(DefaultBundlingEnvironment.runOnce(() -> { + return createBundlingEnvironment(os); + })); + + return new ToolProvider() { + + @Override + public int run(PrintWriter out, PrintWriter err, String... args) { + return Globals.main(() -> { + Globals.instance().objectFactory(of); + return impl.run(out, err, args); + }); + } + + @Override + public String name() { + return impl.name(); + } + }; + } +} diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/cli/OptionsValidationFailTest.excludes b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/cli/OptionsValidationFailTest.excludes index 0e7545bd83d..07757211927 100644 --- a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/cli/OptionsValidationFailTest.excludes +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/cli/OptionsValidationFailTest.excludes @@ -4,7 +4,6 @@ ErrorTest.test(IMAGE; app-desc=Hello; args-add=[--app-version, 1.]; errors=[mess ErrorTest.test(IMAGE; app-desc=Hello; args-add=[--app-version, 1.b.3]; errors=[message.error-header+[error.version-string-invalid-component, 1.b.3, b.3]]) ErrorTest.test(IMAGE; app-desc=Hello; args-add=[--app-version, ]; errors=[message.error-header+[error.version-string-empty]]) ErrorTest.test(IMAGE; app-desc=Hello; args-add=[--jlink-options, --add-modules]; errors=[message.error-header+[error.blocked.option, --add-modules]]) -ErrorTest.test(IMAGE; app-desc=Hello; args-add=[--jlink-options, --foo]; errors=[message.error-header+[error.jlink.failed, Error: unknown option: --foo]]) ErrorTest.test(IMAGE; app-desc=Hello; args-add=[--jlink-options, --module-path]; errors=[message.error-header+[error.blocked.option, --module-path]]) ErrorTest.test(IMAGE; app-desc=Hello; args-add=[--jlink-options, --output]; errors=[message.error-header+[error.blocked.option, --output]]) ErrorTest.test(IMAGE; app-desc=Hello; args-add=[--main-jar, non-existent.jar]; errors=[message.error-header+[error.main-jar-does-not-exist, non-existent.jar]]) diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/cli/OptionsValidationFailTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/cli/OptionsValidationFailTest.java index a85b5015e73..e15d5130d43 100644 --- a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/cli/OptionsValidationFailTest.java +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/cli/OptionsValidationFailTest.java @@ -200,13 +200,13 @@ public class OptionsValidationFailTest { Stream.of("--jpt-run=ErrorTest") ).flatMap(x -> x).toArray(String[]::new)).map(dynamicTest -> { return DynamicTest.dynamicTest(dynamicTest.getDisplayName(), () -> { - JPackageCommand.withToolProvider(jpackageToolProviderMock, () -> { + JPackageCommand.withToolProvider(() -> { try { dynamicTest.getExecutable().execute(); } catch (Throwable t) { throw ExceptionBox.toUnchecked(ExceptionBox.unbox(t)); } - }); + }, jpackageToolProviderMock); }); }); } diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/CommandOutputControlTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/CommandOutputControlTest.java new file mode 100644 index 00000000000..f1d8c142eb9 --- /dev/null +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/CommandOutputControlTest.java @@ -0,0 +1,1846 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal.util; + +import static java.util.stream.Collectors.joining; +import static jdk.jpackage.internal.util.CommandOutputControlTestUtils.isInterleave; +import static jdk.jpackage.internal.util.function.ThrowingConsumer.toConsumer; +import static jdk.jpackage.internal.util.function.ThrowingRunnable.toRunnable; +import static jdk.jpackage.internal.util.function.ThrowingSupplier.toSupplier; +import static jdk.jpackage.test.JUnitUtils.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.io.PrintWriter; +import java.io.StringReader; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.spi.ToolProvider; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import jdk.internal.util.OperatingSystem; +import jdk.jpackage.internal.util.function.ExceptionBox; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.EnumSource.Mode; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +public class CommandOutputControlTest { + + @DisabledIf("cherryPickSavedOutputTestCases") + @ParameterizedTest + @MethodSource + public void testSavedOutput(OutputTestSpec spec) { + spec.test(); + } + + /** + * Runs cherry-picked {@link OutputTestSpec} test cases. + *

+ * This test method is mutual exclusive with + * {@link #testSavedOutput(OutputTestSpec)} and is aimed for debugging + * {@code OutputTestSpec} test cases. + *

+ * It is disabled by default. To enable it, manually edit {@link #testSomeSavedOutput()}. + * + * @see #testSomeSavedOutput() + * + * @param spec the test case + */ + @EnabledIf("cherryPickSavedOutputTestCases") + @ParameterizedTest + @MethodSource + public void testSomeSavedOutput(OutputTestSpec spec) { + System.out.println(spec); + spec.test(); + } + + @ParameterizedTest + @MethodSource + public void testDumpStreams(OutputTestSpec spec) { + spec.test(); + } + + @ParameterizedTest + @MethodSource + public void testCharset(CharsetTestSpec spec) throws IOException, InterruptedException { + spec.test(); + } + + @ParameterizedTest + @MethodSource + public void test_description(CommandOutputControlSpec spec) { + // This test is mostly for coverage. + var desc = spec.create().description(); + assertFalse(desc.isBlank()); + } + + @Test + public void test_copy() { + var orig = new CommandOutputControl(); + var copy = orig.copy(); + assertNotSame(orig, copy); + } + + @ParameterizedTest + @EnumSource(names = "SAVE_NOTHING", mode = Mode.EXCLUDE) + public void test_flag(OutputControl flag) { + var coc = new CommandOutputControl(); + assertFalse(flag.get(coc)); + flag.set(coc); + assertTrue(flag.get(coc)); + if (flag.canUnset()) { + flag.unset(coc); + assertFalse(flag.get(coc)); + } + } + + @ParameterizedTest + @MethodSource + public void test_mutual_exclusive_flags(List controls) { + if (controls.isEmpty()) { + throw new IllegalArgumentException(); + } + + var coc = new CommandOutputControl(); + for (var c : controls) { + c.set(coc); + } + + for (var c : controls.subList(0, controls.size() - 1)) { + assertFalse(c.get(coc)); + } + assertTrue(controls.getLast().get(coc)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_ExecutableAttributes(boolean toolProvider) { + var coc = new CommandOutputControl(); + CommandOutputControl.Executable exec; + if (toolProvider) { + exec = coc.createExecutable(new ToolProvider() { + + @Override + public String name() { + return "runme"; + } + + @Override + public int run(PrintWriter out, PrintWriter err, String... args) { + fail("Should never be called"); + return 0; + } + + }, "--foo", "--baz=10"); + } else { + exec = coc.createExecutable(new ProcessBuilder("runme", "--foo", "--baz=10")); + } + + assertEquals("runme --foo --baz=10", exec.attributes().toString()); + } + + @Test + public void test_Result_no_args_ctor() { + var result = new CommandOutputControl.Result(7); + assertFalse(result.findContent().isPresent()); + assertFalse(result.findStdout().isPresent()); + assertFalse(result.findStderr().isPresent()); + assertEquals(7, result.getExitCode()); + assertSame(Objects.requireNonNull(CommandOutputControl.EMPTY_EXECUTABLE_ATTRIBUTES), result.execAttrs()); + } + + @Test + public void test_Result_expectExitCode() throws IOException { + var result = new CommandOutputControl.Result(7); + + assertSame(result, result.expectExitCode(7)); + assertSame(result, result.expectExitCode(7, 2)); + assertSame(result, result.expectExitCode(2, 7)); + + assertSame(result, result.expectExitCode(List.of(7))); + assertSame(result, result.expectExitCode(Set.of(7, 2))); + assertSame(result, result.expectExitCode(List.of(2, 7))); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_Result_expectExitCode_negative(boolean collection) { + var result = new CommandOutputControl.Result(3); + + var ex = assertThrowsExactly(CommandOutputControl.UnexpectedExitCodeException.class, () -> { + if (collection) { + result.expectExitCode(List.of(17, 12)); + } else { + result.expectExitCode(17, 12); + } + }); + + assertNull(ex.getCause()); + assertSame(result, ex.getResult()); + assertEquals("Unexpected exit code 3 from executing the command ", ex.getMessage()); + } + + @ParameterizedTest + @MethodSource + public void test_Result_toCharacterResult(ToCharacterResultTestSpec spec) throws IOException, InterruptedException { + spec.test(); + } + + @Test + public void test_Result_toCharacterResult_nop() throws IOException, InterruptedException { + + var charset = StandardCharsets.UTF_8; + + var emptyResult = new CommandOutputControl.Result(7); + assertSame(emptyResult, emptyResult.toCharacterResult(charset, true)); + assertSame(emptyResult, emptyResult.toCharacterResult(charset, false)); + + var coc = new CommandOutputControl().saveOutput(true); + + var result = coc.createExecutable(new Command(List.of("foo"), List.of()).asToolProvider()).execute(); + + assertSame(result, result.toCharacterResult(charset, true)); + assertSame(result, result.toCharacterResult(charset, false)); + } + + @Test + public void test_Result_toCharacterResult_copyWithExecutableAttributes() { + + var empty = new CommandOutputControl.Result(0); + + var execAttrs = new CommandOutputControl.ExecutableAttributes() { + @Override + public String toString() { + return "foo"; + } + + @Override + public List commandLine() { + return List.of(); + } + }; + + var copy = empty.copyWithExecutableAttributes(execAttrs); + + assertSame(empty.exitCode(), copy.exitCode()); + assertSame(empty.output(), copy.output()); + assertSame(empty.byteOutput(), copy.byteOutput()); + assertSame(execAttrs, copy.execAttrs()); + } + + @ParameterizedTest + @EnumSource(ExecutableType.class) + public void test_timeout_expires(ExecutableType mode) throws InterruptedException, IOException { + + final var toolProvider = (mode == ExecutableType.TOOL_PROVIDER); + final var storeOutputInFiles = (mode == ExecutableType.PROCESS_BUILDER_WITH_STREAMS_IN_FILES); + + var actions = List.of( + CommandAction.echoStdout("The quick brown fox jumps"), + CommandAction.sleep(5), + CommandAction.echoStdout("over the lazy dog") + ); + + var coc = new CommandOutputControl().saveOutput(true).dumpOutput(true).storeOutputInFiles(storeOutputInFiles); + + CommandOutputControl.Executable exec; + + InterruptibleToolProvider tp; + + if (toolProvider) { + tp = new InterruptibleToolProvider(Command.createToolProvider(actions)); + exec = coc.createExecutable(tp); + } else { + var cmdline = Command.createShellCommandLine(actions); + tp = null; + exec = coc.createExecutable(new ProcessBuilder(cmdline)); + } + + var result = exec.execute(1, TimeUnit.SECONDS); + assertFalse(result.exitCode().isPresent()); + + var getExitCodeEx = assertThrowsExactly(IllegalStateException.class, result::getExitCode); + assertEquals(("Exit code is unavailable for timed-out command"), getExitCodeEx.getMessage()); + + // We want to check that the saved output contains only the text emitted before the "sleep" action. + // It works for a subprocess, but in the case of a ToolProvider, sometimes the timing is such + // that it gets interrupted before having written anything to the stdout, and the saved output is empty. + // This happens when the test case is executed together with other test cases + // and never when it is executed individually. + if (!toolProvider || !result.content().isEmpty()) { + assertEquals(List.of("The quick brown fox jumps"), result.content()); + } + + if (toolProvider) { + assertTrue(tp.interrupted()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_timeout(boolean toolProvider) throws InterruptedException, IOException { + + var actions = List.of( + CommandAction.echoStdout("Sphinx of black quartz,"), + CommandAction.echoStdout("judge my vow") + ); + + var coc = new CommandOutputControl().saveOutput(true).dumpOutput(true); + + CommandOutputControl.Executable exec; + + if (toolProvider) { + var tp = Command.createToolProvider(actions); + exec = coc.createExecutable(tp); + } else { + var cmdline = Command.createShellCommandLine(actions); + exec = coc.createExecutable(new ProcessBuilder(cmdline)); + } + + var result = exec.execute(10, TimeUnit.SECONDS); + assertTrue(result.exitCode().isPresent()); + assertEquals(List.of("Sphinx of black quartz,", "judge my vow"), result.content()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_passthrough_exceptions(boolean withTimeout) throws IOException { + + var expected = new RuntimeException("Kaput!"); + + var exec = new CommandOutputControl().createExecutable(new ToolProvider() { + + @Override + public String name() { + return "foo"; + } + + @Override + public int run(PrintWriter out, PrintWriter err, String... args) { + throw expected; + } + }); + + var actual = assertThrowsExactly(expected.getClass(), () -> { + if (withTimeout) { + exec.execute(10, TimeUnit.SECONDS); + } else { + exec.execute(); + } + }); + + assertSame(expected, actual); + } + + @Test + public void test_externally_terminated() throws InterruptedException, IOException { + var cmdline = Command.createShellCommandLine(List.of( + CommandAction.echoStderr("The five boxing wizards"), + CommandAction.sleep(10), + CommandAction.echoStderr("jump quickly") + )); + + var processDestroyer = Slot.>createEmpty(); + + var coc = new CommandOutputControl().saveOutput(true).dumpOutput(true).processListener(process -> { + // Once we are notified the process has been started, schedule its destruction. + // Give it a second to warm up and print some output and then destroy it. + processDestroyer.set(CompletableFuture.runAsync(toRunnable(() -> { + Thread.sleep(Duration.ofSeconds(1)); + // On Windows, CommandAction#sleep is implemented with the "ping" command. + // By some reason, when the parent "cmd" process is destroyed, + // the child "ping" command stays alive, and the test waits when it completes, + // making it last for at least 10 seconds. + // To optimize the test work time, destroy the entire subprocess tree. + // Even though this is essential on Windows keep this logic on all platforms for simplicity. + var descendants = List.of(); + try (var descendantsStream = process.descendants()) { + descendants = descendantsStream.toList(); + } finally { + process.destroyForcibly(); + } + descendants.forEach(ProcessHandle::destroyForcibly); + }))); + }); + var exec = coc.createExecutable(new ProcessBuilder(cmdline)); + + var result = exec.execute(); + assertNotEquals(0, result.getExitCode()); + assertEquals(List.of("The five boxing wizards"), result.content()); + processDestroyer.get().join(); + } + + @DisabledOnOs(value = OS.MAC, disabledReason = "Closing a stream doesn't consistently cause a trouble as it should") + @ParameterizedTest + @EnumSource(OutputStreams.class) + public void test_close_streams(OutputStreams action) throws InterruptedException, IOException { + var cmdline = Command.createShellCommandLine(List.of( + CommandAction.echoStdout("Hello stdout"), + CommandAction.echoStderr("Bye stderr") + )); + + var coc = new CommandOutputControl().saveOutput(true).dumpOutput(true).processListener(toConsumer(process -> { + // Close process output stream(s). This should make corresponding stream gobbler(s) throw IOException. + switch (action) { + case STDOUT -> { + process.getInputStream().close(); + } + case STDERR -> { + process.getErrorStream().close(); + } + case STDOUT_AND_STDERR -> { + process.getInputStream().close(); + process.getErrorStream().close(); + } + } + })); + var exec = coc.createExecutable(new ProcessBuilder(cmdline)); + + var ex = assertThrows(IOException.class, exec::execute); + System.out.println("test_close_streams: " + action); + ex.printStackTrace(System.out); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_interleaved(boolean customDumpStreams) throws IOException, InterruptedException { + var cmdline = Command.createShellCommandLine(List.of( + CommandAction.echoStdout("Eat some more"), + CommandAction.echoStderr("of these"), + CommandAction.echoStdout("soft French pastries"), + CommandAction.echoStderr("and drink some tea") + )); + + var coc = new CommandOutputControl(); + var exec = coc.createExecutable(new ProcessBuilder(cmdline)); + + coc.saveOutput(true).dumpOutput(true); + + CommandOutputControl.Result result; + + if (customDumpStreams) { + // Execute the command so that its stdout and stderr are dumped to the same sink. + var sink = new ByteArrayOutputStream(); + var ps = new PrintStream(sink); + + coc.dumpStdout(ps).dumpStderr(ps); + + result = exec.execute(); + + var commandStdout = List.of("Eat some more", "soft French pastries"); + var commandStderr = List.of("of these", "and drink some tea"); + + var sinkContent = toStringList(sink.toByteArray(), StandardCharsets.US_ASCII); + + if (!isInterleave(sinkContent, commandStdout, commandStderr)) { + fail(String.format("Unexpected combined output=%s; stdout=%s; stderr=%s", + sinkContent, commandStdout, commandStderr)); + } + + // CommandOutputControl was not configured to redirect stderr in stdout, + // hence the output is ordered: stdout goes first, stderr follows. + assertEquals(Stream.of(commandStdout, commandStderr).flatMap(List::stream).toList(), result.content()); + + // Saved stdout an stderr can be accessed individually. + assertEquals(commandStdout, result.stdout()); + assertEquals(commandStderr, result.stderr()); + } else { + // Execute the command so that its stdout and stderr are dumped into System.out. + coc.redirectStderr(true); + result = exec.execute(); + + // CommandOutputControl was configured to redirect stderr in stdout, + // hence the output is interleaved. + assertEquals(List.of("Eat some more", "of these", "soft French pastries", "and drink some tea"), result.content()); + + // Saved stdout an stderr can NOT be accessed individually because they are interleaved. + assertTrue(result.findStdout().isEmpty()); + assertTrue(result.findStderr().isEmpty()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true}) + public void stressTest(boolean binaryOutput, @TempDir Path workDir) throws Exception { + + // Execute multiple subprocesses asynchronously. + // Each subprocess writes a few chunks of data each larger than the default buffer size (8192 bytes) + + final var chunkCount = 5; + final var subprocessCount = 100; + final var subprocessExecutor = Executors.newVirtualThreadPerTaskExecutor(); + + final var md = MessageDigest.getInstance("MD5"); + + var cmdline = Command.createShellCommandLine(IntStream.range(0, chunkCount).mapToObj(chunk -> { + byte[] bytes = new byte[10 * 1024]; // 10K to exceed the default BufferedOutputStream's buffer size of 8192. + new Random().nextBytes(bytes); + md.update(bytes); + var path = workDir.resolve(Integer.toString(chunk)); + try { + Files.write(path, bytes); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + return path; + }).map(CommandAction::cat).toList()); + + final var digest = HexFormat.of().formatHex(md.digest()); + + // Schedule to start every subprocess in a separate virtual thread. + // Start and suspend threads, waiting until all scheduled threads have started. + // After all scheduled threads start, resume them. + // This should result in starting all scheduled subprocesses simultaneously. + + var readyLatch = new CountDownLatch(subprocessCount); + var startLatch = new CountDownLatch(1); + + var futures = IntStream.range(0, subprocessCount).mapToObj(_ -> { + return CompletableFuture.supplyAsync(toSupplier(() -> { + + var exec = new CommandOutputControl() + .saveOutput(true) + .binaryOutput(binaryOutput) + .createExecutable(new ProcessBuilder(cmdline)); + + readyLatch.countDown(); + startLatch.await(); + + var result = exec.execute(); + + var localMd = MessageDigest.getInstance("MD5"); + localMd.update(result.byteContent()); + + return HexFormat.of().formatHex(localMd.digest()); + + }), subprocessExecutor); + }).toList(); + + readyLatch.await(); + startLatch.countDown(); + + CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join(); + + futures.forEach(future -> { + var actualDigest = future.join(); + assertEquals(digest, actualDigest); + }); + } + + public enum OutputStreams { + STDOUT, + STDERR, + STDOUT_AND_STDERR + } + + private static List test_description() { + List testCases = new ArrayList<>(); + testCases.add(new CommandOutputControlSpec(Set.of())); + for (var outputControl : OutputControl.variants()) { + testCases.add(new CommandOutputControlSpec(outputControl)); + } + return testCases; + } + + private static List> test_mutual_exclusive_flags() { + List> data = new ArrayList<>(); + + var flags = List.of(OutputControl.SAVE_ALL, OutputControl.SAVE_FIRST_LINE, OutputControl.SAVE_NOTHING); + + List seq = new ArrayList<>(); + for (var _1 : flags) { + seq.add(_1); + var flags2 = flags.stream().filter(Predicate.isEqual(_1).negate()).toList(); + for (var _2 : flags2) { + seq.add(_2); + var flags3 = flags2.stream().filter(Predicate.isEqual(_2).negate()).toList(); + for (var _3 : flags3) { + seq.add(_3); + data.add(List.copyOf(seq)); + seq.removeLast(); + } + seq.removeLast(); + } + seq.removeLast(); + } + + return data; + } + + public record ToCharacterResultTestSpec(OutputTestSpec execSpec, boolean keepByteContent) { + + public ToCharacterResultTestSpec { + Objects.requireNonNull(execSpec); + } + + @Override + public String toString() { + final List tokens = new ArrayList<>(); + + tokens.add(execSpec.toString()); + if (keepByteContent) { + tokens.add("keepByteContent"); + } + + return String.join(", ", tokens.toArray(String[]::new)); + } + + void test() throws IOException, InterruptedException { + var coc = execSpec.cocSpec().create(); + + var command = execSpec.commandSpec().command().asToolProvider(); + + var expected = coc.binaryOutput(false).createExecutable(command).execute(); + + var byteResult = coc.binaryOutput(true).createExecutable(command).execute(); + + var actual = byteResult.toCharacterResult(coc.charset(), keepByteContent); + + CommandOutputControl.Result expectedByteContent; + if (keepByteContent) { + expectedByteContent = byteResult; + } else { + expectedByteContent = expected; + } + + assertArrayEquals(expectedByteContent.findByteContent().orElse(null), actual.findByteContent().orElse(null)); + assertArrayEquals(expectedByteContent.findByteStdout().orElse(null), actual.findByteStdout().orElse(null)); + assertArrayEquals(expectedByteContent.findByteStderr().orElse(null), actual.findByteStderr().orElse(null)); + + assertEquals(expected.findContent(), actual.findContent()); + assertEquals(expected.findStdout(), actual.findStdout()); + assertEquals(expected.findStderr(), actual.findStderr()); + + assertSame(byteResult.execAttrs(), actual.execAttrs()); + assertEquals(expected.exitCode(), actual.exitCode()); + } + } + + private static Stream test_Result_toCharacterResult() { + List testCases = new ArrayList<>(); + + var skip = Set.of(OutputControl.BINARY_OUTPUT, OutputControl.DUMP, OutputControl.SAVE_FIRST_LINE); + + for (var outputControl : OutputControl.variants().stream().filter(spec -> { + return !skip.stream().anyMatch(spec::contains); + }).toList()) { + for (var stdoutContent : List.of(OutputData.EMPTY, OutputData.MANY)) { + for (var stderrContent : List.of(OutputData.EMPTY, OutputData.MANY)) { + var commandSpec = new CommandSpec(stdoutContent, stderrContent); + testCases.add(new OutputTestSpec(false, new CommandOutputControlSpec(outputControl), commandSpec)); + } + } + } + + return testCases.stream().flatMap(execSpec -> { + return Stream.of(true, false).map(keepByteContent -> { + return new ToCharacterResultTestSpec(execSpec, keepByteContent); + }); + }); + } + + private static boolean cherryPickSavedOutputTestCases() { + return !testSomeSavedOutput().isEmpty(); + } + + /** + * Returns test cases for {@link #testSomeSavedOutput(OutputTestSpec)}. + *

+ * Aimed to simplify debugging of {@link #OutputTestSpec} test cases. + *

+ * The total number of {@code #OutputTestSpec} test cases is ~1500. When some + * fail and need debugging, it is a waste of time to run them all. This method + * allows running only selected test cases. It works this way: + *

    + *
  • Run CommandOutputControlTest test. + *
  • If some {@linke #testSavedOutput(OutputTestSpec)} invocations fail, + * capture their IDs (test case ID is an index starting from 1). + *
  • Replace "/* 10, 67, 456 */" comment in the body of this method with + * the captured test case IDs. + *
  • Rerun CommandOutputControlTest test. This time, it will run + * {@link #testSomeSavedOutput(OutputTestSpec)} method instead of + * {@link #testSavedOutput(OutputTestSpec)} with the list of the captured test + * case IDs. + *
+ */ + private static List testSomeSavedOutput() { + var testIds = List.of(/* 10, 67, 456 */); + if (testIds.isEmpty()) { + return List.of(); + } else { + var allTestCases = testSavedOutput(); + return testIds.stream().map(testId -> { + return allTestCases.get(testId - 1); + }).toList(); + } + } + + private static List testSavedOutput() { + List testCases = new ArrayList<>(); + for (final var executableType : List.of(ExecutableType.values())) { + for (var outputControl : OutputControl.variants()) { + for (final var stdoutContent : List.of(OutputData.values())) { + for (final var stderrContent : List.of(OutputData.values())) { + + if (outputControl.contains(OutputControl.BINARY_OUTPUT) + && (stdoutContent == OutputData.ONE_LINE || stderrContent == OutputData.ONE_LINE)) { + // Skip a test case if it runs a command writing + // a single line in stdout or stderr, and handles command output as a byte stream. + // It duplicates test cases that write multiple lines in stdout or stderr. + continue; + } + + final var commandSpec = new CommandSpec(stdoutContent, stderrContent); + boolean toolProvider; + switch (executableType) { + case PROCESS_BUILDER -> { + toolProvider = false; + } + case PROCESS_BUILDER_WITH_STREAMS_IN_FILES -> { + outputControl = new SetBuilder() + .add(outputControl) + .add(OutputControl.STORE_STREAMS_IN_FILES) + .create(); + toolProvider = false; + } + case TOOL_PROVIDER -> { + toolProvider = true; + } + default -> { + // Unreachable + throw ExceptionBox.reachedUnreachable(); + } + } + testCases.add(new OutputTestSpec( + toolProvider, + new CommandOutputControlSpec(outputControl), + commandSpec)); + } + } + } + } + return testCases; + } + + private static List testDumpStreams() { + List testCases = new ArrayList<>(); + final var commandSpec = new CommandSpec(OutputData.MANY, OutputData.MANY); + for (var discardStdout : withAndWithout(OutputControl.DISCARD_STDOUT)) { + for (var discardStderr : withAndWithout(OutputControl.DISCARD_STDERR)) { + for (var redirectStderr : withAndWithout(OutputControl.REDIRECT_STDERR)) { + for (var binaryOutput : withAndWithout(OutputControl.BINARY_OUTPUT)) { + for (var dumpStdout : withAndWithout(OutputControl.DUMP_STDOUT_IN_SYSTEM_OUT)) { + for (var dumpStderr : withAndWithout(OutputControl.DUMP_STDERR_IN_SYSTEM_ERR)) { + + if (dumpStderr.isEmpty() && dumpStdout.isEmpty()) { + // Output dumping disabled + continue; + } + + if (discardStderr.isPresent() && discardStdout.isPresent()) { + // Output dumping enabled, but all stream discarded + continue; + } + + if (dumpStderr.isPresent() == discardStderr.isPresent() && dumpStdout.isEmpty()) { + // Stderr dumping enabled but discarded, stdout dumping disabled + continue; + } + + if (dumpStdout.isPresent() == discardStdout.isPresent() && dumpStderr.isEmpty()) { + // Stdout dumping enabled but discarded, stderr dumping disabled + continue; + } + + final var outputControl = new HashSet(); + outputControl.add(OutputControl.DUMP); + discardStdout.ifPresent(outputControl::add); + discardStderr.ifPresent(outputControl::add); + redirectStderr.ifPresent(outputControl::add); + binaryOutput.ifPresent(outputControl::add); + dumpStdout.ifPresent(outputControl::add); + dumpStderr.ifPresent(outputControl::add); + + testCases.add(new OutputTestSpec( + false, + new CommandOutputControlSpec(outputControl), + commandSpec)); + } + } + } + } + } + } + return testCases; + } + + private static List testCharset() { + List testCases = new ArrayList<>(); + + for (boolean toolProvider : BOOLEAN_VALUES) { + for (var redirectStderr : withAndWithout(OutputControl.REDIRECT_STDERR)) { + for (var charset : withAndWithout(OutputControl.CHARSET_UTF16LE)) { + var stdoutSink = new CharsetTestSpec.DumpOutputSink(StandardCharsets.US_ASCII, OutputStreams.STDOUT); + var stderrSink = new CharsetTestSpec.DumpOutputSink(StandardCharsets.UTF_32LE, OutputStreams.STDERR); + var outputControl = new HashSet(); + redirectStderr.ifPresent(outputControl::add); + charset.ifPresent(outputControl::add); + outputControl.add(stdoutSink); + outputControl.add(stderrSink); + testCases.add(new CharsetTestSpec(toolProvider, new CommandOutputControlSpec(outputControl))); + } + } + } + + return testCases; + } + + private enum ExecutableType { + TOOL_PROVIDER, + PROCESS_BUILDER, + PROCESS_BUILDER_WITH_STREAMS_IN_FILES, + ; + } + + private sealed interface CommandAction { + static SleepCommandAction sleep(int seconds) { + return new SleepCommandAction(seconds); + } + + static EchoCommandAction echoStdout(String str) { + return new EchoCommandAction(str, false); + } + + static EchoCommandAction echoStderr(String str) { + return new EchoCommandAction(str, true); + } + + static WriteCommandAction writeStdout(byte[] binary) { + return new WriteCommandAction(binary, false); + } + + static WriteCommandAction writeStderr(byte[] binary) { + return new WriteCommandAction(binary, true); + } + + static CatCommandAction cat(Path file) { + return new CatCommandAction(file); + } + } + + private record EchoCommandAction(String value, boolean stderr) implements CommandAction { + EchoCommandAction { + Objects.requireNonNull(value); + } + } + + private record WriteCommandAction(byte[] value, boolean stderr) implements CommandAction { + WriteCommandAction { + Objects.requireNonNull(value); + } + } + + private record CatCommandAction(Path file) implements CommandAction { + CatCommandAction { + Objects.requireNonNull(file); + } + } + + private record SleepCommandAction(int seconds) implements CommandAction { + SleepCommandAction { + if (seconds < 0) { + throw new IllegalArgumentException(); + } + } + } + + private record Command(List stdout, List stderr) { + Command { + stdout.forEach(Objects::requireNonNull); + stderr.forEach(Objects::requireNonNull); + } + + List asExecutable() { + return createShellCommandLine(actions()); + } + + ToolProvider asToolProvider() { + return createToolProvider(actions()); + } + + // + // Type of shell for which to create a command line. + // On Unix it is always the "sh". + // On Windows, it is "cmd" by default and "powershell" when a command needs to write binary data to output stream(s). + // Extra complexity on Windows is because "powershell" is times slower than "cmd", + // and the latter doesn't support binary output. + // + private enum ShellType { + SH(OperatingSystem.LINUX, OperatingSystem.MACOS), + CMD(OperatingSystem.WINDOWS), + POWERSHELL(OperatingSystem.WINDOWS), + ; + + ShellType(OperatingSystem... os) { + if (os.length == 0) { + throw new IllegalArgumentException(); + } + this.os = Set.of(os); + } + + boolean isSupportedOnCurrentOS() { + return os.contains(OperatingSystem.current()); + } + + private final Set os; + } + + private List actions() { + return Stream.concat( + stdout.stream().map(CommandAction::echoStdout), + stderr.stream().map(CommandAction::echoStderr) + ).toList(); + } + + static List createShellCommandLine(List actions) { + final var shellType = detectShellType(actions); + final List commandline = new ArrayList<>(); + final String commandSeparator; + switch (shellType) { + case SH -> { + commandline.addAll(List.of("sh", "-c")); + commandSeparator = " && "; + } + case CMD -> { + commandline.addAll(List.of("cmd", "/C")); + commandSeparator = " && "; + } + case POWERSHELL -> { + commandline.addAll(List.of("powershell", "-NoProfile", "-Command")); + commandSeparator = "; "; + } + default -> { + // Unreachable + throw ExceptionBox.reachedUnreachable(); + } + } + commandline.add(actions.stream().map(action -> { + return Command.toString(action, shellType); + }).collect(joining(commandSeparator))); + return commandline; + } + + static ToolProvider createToolProvider(List actions) { + var copiedActions = List.copyOf(actions); + return new ToolProvider() { + + @Override + public int run(PrintWriter out, PrintWriter err, String... args) { + throw new UnsupportedOperationException(); + } + + @Override + public int run(PrintStream out, PrintStream err, String... args) { + for (var action : copiedActions) { + switch (action) { + case EchoCommandAction echo -> { + if (echo.stderr()) { + err.println(echo.value()); + } else { + out.println(echo.value()); + } + } + case WriteCommandAction write -> { + try { + if (write.stderr()) { + err.write(write.value()); + } else { + out.write(write.value()); + } + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + case SleepCommandAction sleep -> { + toRunnable(() -> { + synchronized (this) { + var millis = Duration.ofSeconds(sleep.seconds()).toMillis(); + this.wait(millis); + } + }).run(); + } + case CatCommandAction _ -> { + // Not used, no point to implement. + throw new UnsupportedOperationException(); + } + } + } + return 0; + } + + @Override + public String name() { + return "test"; + } + }; + } + + private static ShellType detectShellType(List actions) { + var supportedShellTypes = Stream.of(ShellType.values()) + .filter(ShellType::isSupportedOnCurrentOS) + .collect(Collectors.toCollection(HashSet::new)); + for (var action : actions) { + if (action instanceof WriteCommandAction) { + supportedShellTypes.remove(ShellType.CMD); + } + } + return supportedShellTypes.stream() + .sorted(Comparator.comparingInt(Enum::ordinal)) + .findFirst().orElseThrow(); + } + + private static String toString(CommandAction action, ShellType shellType) { + switch (action) { + case EchoCommandAction a -> { + return toString(a, shellType); + } + case WriteCommandAction a -> { + return toString(a, shellType); + } + case SleepCommandAction a -> { + return toString(a, shellType); + } + case CatCommandAction a -> { + return toString(a, shellType); + } + } + } + + private static String toString(EchoCommandAction echo, ShellType shellType) { + String str; + switch (shellType) { + case SH -> { + str = "echo " + echo.value(); + if (echo.stderr()) { + str += ">&2"; + } + } + case CMD -> { + str = "(echo " + echo.value() + ")"; + if (echo.stderr()) { + str += ">&2"; + } + } + case POWERSHELL -> { + str = String.format("[Console]::%s.WriteLine(\\\"%s\\\")", + echo.stderr() ? "Error" : "Out", echo.value()); + } + default -> { + // Unreachable + throw ExceptionBox.reachedUnreachable(); + } + } + return str; + } + + private static String toString(WriteCommandAction write, ShellType shellType) { + String str; + switch (shellType) { + case SH -> { + // Convert byte[] to octal string to make it work with POSIX printf. + // POSIX printf doesn't recognize hex strings, so can't use handy HexFormat. + var sb = new StringBuilder(); + sb.append("printf "); + for (var b : write.value()) { + sb.append("\\\\").append(Integer.toOctalString(b & 0xFF)); + } + if (write.stderr()) { + sb.append(">&2"); + } + str = sb.toString(); + } + case CMD -> { + throw new UnsupportedOperationException("Can't output binary data with 'cmd'"); + } + case POWERSHELL -> { + var base64 = Base64.getEncoder().encodeToString(write.value()); + str = String.format( + "$base64 = '%s'; " + + "$bytes = [Convert]::FromBase64String($base64); " + + "[Console]::%s().Write($bytes, 0, $bytes.Length)", + base64, write.stderr() ? "OpenStandardError" : "OpenStandardOutput"); + } + default -> { + // Unreachable + throw ExceptionBox.reachedUnreachable(); + } + } + return str; + } + + private static String toString(SleepCommandAction sleep, ShellType shellType) { + switch (shellType) { + case SH -> { + return "sleep " + sleep.seconds(); + } + case CMD -> { + // The standard way to sleep in "cmd" is to use the "ping" command. + // It sends packets every second. + // To wait N seconds, it should send N+1 packets. + // The "timeout" command works only in a console. + return String.format("(ping -n %d localhost > nul)", sleep.seconds() + 1); + } + case POWERSHELL -> { + return "Start-Sleep -Seconds " + sleep.seconds(); + } + default -> { + // Unreachable + throw ExceptionBox.reachedUnreachable(); + } + } + } + + private static String toString(CatCommandAction cat, ShellType shellType) { + switch (shellType) { + case SH -> { + return "cat " + cat.file(); + } + case CMD -> { + return "type " + cat.file(); + } + case POWERSHELL -> { + // Not used, no point to implement. + throw new UnsupportedOperationException(); + } + default -> { + // Unreachable + throw ExceptionBox.reachedUnreachable(); + } + } + } + + } + + private enum OutputData { + EMPTY(List.of()), + ONE_LINE(List.of("Jupiter")), + MANY(List.of("Uranus", "Saturn", "Earth")); + + OutputData(List data) { + data.forEach(Objects::requireNonNull); + this.data = data; + } + + final List data; + } + + private record CommandSpec(OutputData stdout, OutputData stderr) { + CommandSpec { + Objects.requireNonNull(stdout); + Objects.requireNonNull(stderr); + } + + @Override + public String toString() { + return String.format("[stdout=%s, stderr=%s]", stdout, stderr); + } + + Command command() { + return new Command(stdout.data.stream().map(line -> { + return "stdout." + line; + }).toList(), stderr.data.stream().map(line -> { + return "stderr." + line; + }).toList()); + } + } + + public interface CommandOutputControlMutator { + String name(); + void mutate(CommandOutputControl coc); + + static Function> addToSet(Set set) { + return m -> { + return new SetBuilder().add(set).add(m).create(); + }; + } + } + + public enum OutputControl implements CommandOutputControlMutator { + DUMP(CommandOutputControl::dumpOutput, CommandOutputControl::isDumpOutput), + SAVE_ALL(CommandOutputControl::saveOutput, CommandOutputControl::isSaveOutput), + SAVE_FIRST_LINE(CommandOutputControl::saveFirstLineOfOutput, CommandOutputControl::isSaveFirstLineOfOutput), + SAVE_NOTHING(coc -> { + coc.saveOutput(false); + }, coc -> { + return !coc.isSaveOutput() && !coc.isSaveFirstLineOfOutput(); + }), + DISCARD_STDOUT(CommandOutputControl::discardStdout, CommandOutputControl::isDiscardStdout), + DISCARD_STDERR(CommandOutputControl::discardStderr, CommandOutputControl::isDiscardStderr), + REDIRECT_STDERR(CommandOutputControl::redirectStderr, CommandOutputControl::isRedirectStderr), + STORE_STREAMS_IN_FILES(CommandOutputControl::storeOutputInFiles, CommandOutputControl::isStoreOutputInFiles), + BINARY_OUTPUT(CommandOutputControl::binaryOutput, CommandOutputControl::isBinaryOutput), + DUMP_STDOUT_IN_SYSTEM_OUT(coc -> { + coc.dumpStdout(new PrintStreamWrapper(System.out)); + }, coc -> { + return coc.dumpStdout() instanceof PrintStreamWrapper; + }), + DUMP_STDERR_IN_SYSTEM_ERR(coc -> { + coc.dumpStderr(new PrintStreamWrapper(System.err)); + }, coc -> { + return coc.dumpStderr() instanceof PrintStreamWrapper; + }), + CHARSET_UTF16LE(coc -> { + coc.charset(StandardCharsets.UTF_16LE); + }, coc -> { + return coc.charset() == StandardCharsets.UTF_16LE; + }), + ; + + OutputControl(Consumer setter, Function getter) { + this.setter = Objects.requireNonNull(setter); + this.unsetter = null; + this.getter = Objects.requireNonNull(getter); + } + + OutputControl(BiConsumer setter, Function getter) { + Objects.requireNonNull(setter); + this.setter = coc -> { + setter.accept(coc, true); + }; + this.unsetter = coc -> { + setter.accept(coc, false); + }; + this.getter = Objects.requireNonNull(getter); + } + + @Override + public void mutate(CommandOutputControl coc) { + set(coc); + } + + CommandOutputControl set(CommandOutputControl coc) { + setter.accept(coc); + return coc; + } + + CommandOutputControl unset(CommandOutputControl coc) { + Objects.requireNonNull(unsetter).accept(coc); + return coc; + } + + boolean canUnset() { + return unsetter != null; + } + + boolean get(CommandOutputControl coc) { + return getter.apply(coc); + } + + static List> variants() { + final List> variants = new ArrayList<>(); + for (final var binaryOutput : withAndWithout(BINARY_OUTPUT)) { + for (final var redirectStderr : withAndWithout(REDIRECT_STDERR)) { + for (final var withDump : withAndWithout(DUMP)) { + variants.addAll(Stream.of( + Set.of(), + Set.of(SAVE_ALL), + Set.of(SAVE_FIRST_LINE), + Set.of(DISCARD_STDOUT), + Set.of(DISCARD_STDERR), + Set.of(SAVE_ALL, DISCARD_STDOUT), + Set.of(SAVE_FIRST_LINE, DISCARD_STDOUT), + Set.of(SAVE_ALL, DISCARD_STDERR), + Set.of(SAVE_FIRST_LINE, DISCARD_STDERR), + Set.of(SAVE_ALL, DISCARD_STDOUT, DISCARD_STDERR), + Set.of(SAVE_FIRST_LINE, DISCARD_STDOUT, DISCARD_STDERR) + ).map(v -> { + return withDump.map(CommandOutputControlMutator.addToSet(v)).orElse(v); + }).map(v -> { + return redirectStderr.filter(_ -> { + return !v.containsAll(List.of(DISCARD_STDOUT, DISCARD_STDERR)); + }).map(CommandOutputControlMutator.addToSet(v)).orElse(v); + }).map(v -> { + return binaryOutput.map(CommandOutputControlMutator.addToSet(v)).orElse(v); + }).toList()); + } + } + } + return variants.stream().distinct().toList(); + } + + private static final class PrintStreamWrapper extends PrintStream { + PrintStreamWrapper(PrintStream out) { + super(out, true); + } + } + + private final Consumer setter; + private final Consumer unsetter; + private final Function getter; + + static final Set SAVE = Set.of(SAVE_ALL, SAVE_FIRST_LINE); + } + + public record CommandOutputControlSpec(Set outputControl) { + public CommandOutputControlSpec { + outputControl.forEach(Objects::requireNonNull); + if (outputControl.containsAll(OutputControl.SAVE)) { + throw new IllegalArgumentException(); + } + } + + @Override + public String toString() { + return outputControl.stream().map(CommandOutputControlMutator::name).sorted().collect(joining("+")); + } + + boolean contains(OutputControl v) { + return outputControl.contains(Objects.requireNonNull(v)); + } + + boolean dumpOutput() { + return contains(OutputControl.DUMP); + } + + boolean saveOutput() { + return !Collections.disjoint(outputControl, OutputControl.SAVE); + } + + boolean discardStdout() { + return contains(OutputControl.DISCARD_STDOUT); + } + + boolean discardStderr() { + return contains(OutputControl.DISCARD_STDERR); + } + + boolean redirectStderr() { + return contains(OutputControl.REDIRECT_STDERR); + } + + CommandOutputControl create() { + final CommandOutputControl coc = new CommandOutputControl(); + outputControl.forEach(control -> control.mutate(coc)); + return coc; + } + } + + public record OutputTestSpec(boolean toolProvider, CommandOutputControlSpec cocSpec, CommandSpec commandSpec) { + public OutputTestSpec { + Objects.requireNonNull(cocSpec); + Objects.requireNonNull(commandSpec); + } + + @Override + public String toString() { + final List tokens = new ArrayList<>(); + + if (toolProvider) { + tokens.add("tool-provider"); + } + + tokens.add("output=" + cocSpec.toString()); + tokens.add("command=" + commandSpec); + + return String.join(", ", tokens.toArray(String[]::new)); + } + + void test() { + final var command = commandSpec.command(); + + final Slot result = Slot.createEmpty(); + final var dumpCapture = DumpCapture.captureDump(toRunnable(() -> { + result.set(createExecutable(command).execute()); + })); + + assertEquals(0, result.get().getExitCode()); + + verifyDump(dumpCapture, command); + if (contains(OutputControl.BINARY_OUTPUT)) { + verifyByteResultContent(result.get(), command, StandardCharsets.UTF_8); + } else { + verifyResultContent(result.get(), command); + } + } + + private boolean contains(OutputControl v) { + return cocSpec.contains(v); + } + + private boolean dumpOutput() { + return cocSpec.dumpOutput(); + } + + private boolean saveOutput() { + return cocSpec.saveOutput(); + } + + private boolean discardStdout() { + return cocSpec.discardStdout(); + } + + private boolean discardStderr() { + return cocSpec.discardStderr(); + } + + private boolean redirectStderr() { + return cocSpec.redirectStderr(); + } + + private boolean replaceStdoutWithStderr() { + return redirectStderr() && discardStdout() && !discardStderr(); + } + + private boolean stdoutInherited() { + if (toolProvider || saveOutput() || replaceStdoutWithStderr()) { + return false; + } + return dumpOutput() && !discardStdout() && !contains(OutputControl.DUMP_STDOUT_IN_SYSTEM_OUT); + } + + private boolean stderrInherited() { + if (toolProvider || saveOutput() || redirectStderr()) { + return false; + } + return dumpOutput() && !discardStderr() && !contains(OutputControl.DUMP_STDERR_IN_SYSTEM_ERR); + } + + private void verifyDump(DumpCapture dumpCapture, Command command) { + if (!dumpOutput()) { + assertEquals(List.of(), dumpCapture.outLines()); + assertEquals(List.of(), dumpCapture.errLines()); + return; + } + + if (replaceStdoutWithStderr()) { + // STDERR replaces STDOUT + assertEquals(command.stderr(), dumpCapture.outLines()); + assertEquals(List.of(), dumpCapture.errLines()); + return; + } + + verifyDumpedStdout(dumpCapture, command); + verifyDumpedStderr(dumpCapture, command); + } + + private void verifyDumpedStdout(DumpCapture dumpCapture, Command command) { + if (stdoutInherited()) { + // A subprocess wrote its STDOUT into a file descriptor associated + // with the Java process's STDOUT, not into System.out. Can't capture it. + assertEquals(List.of(), dumpCapture.outLines()); + return; + } + + if (redirectStderr() && !discardStderr()) { + // Interleaved STDOUT and STDERR + if (!isInterleave(dumpCapture.outLines(), command.stdout(), command.stderr())) { + fail(String.format("Unexpected combined output=%s; stdout=%s; stderr=%s", + dumpCapture.outLines(), command.stdout(), command.stderr())); + } + } else if (discardStdout()) { + assertEquals(List.of(), dumpCapture.outLines()); + } else { + assertEquals(command.stdout(), dumpCapture.outLines()); + } + } + + private void verifyDumpedStderr(DumpCapture dumpCapture, Command command) { + if (stderrInherited()) { + // A subprocess wrote its STDERR into a file descriptor associated + // with the Java process's STDERR, not into System.err. Can't capture it. + assertEquals(List.of(), dumpCapture.errLines()); + return; + } + + if (redirectStderr() || discardStderr()) { + assertEquals(List.of(), dumpCapture.errLines()); + } else { + assertEquals(command.stderr(), dumpCapture.errLines()); + } + } + + private void verifyResultContent(CommandOutputControl.Result result, Command command) { + Objects.requireNonNull(result); + Objects.requireNonNull(command); + + assertTrue(result.findByteContent().isEmpty()); + assertTrue(result.findByteStdout().isEmpty()); + assertTrue(result.findByteStderr().isEmpty()); + + if (!saveOutput()) { + assertTrue(result.findContent().isEmpty()); + assertTrue(result.findStdout().isEmpty()); + assertTrue(result.findStderr().isEmpty()); + return; + } + + assertTrue(result.findContent().isPresent()); + + command = filterSavedStreams(command); + + var content = result.content(); + + if (contains(OutputControl.SAVE_FIRST_LINE)) { + assertTrue(content.size() <= 2, String.format("The number of saved lines must be less than or equal to two. Actual: %d", result.content().size())); + } + + if (!redirectStderr()) { + var stdout = result.stdout(); + var stderr = result.stderr(); + + assertEquals(command.stdout(), stdout); + assertEquals(command.stderr(), stderr); + assertEquals(Stream.of( + stdout, + stderr + ).flatMap(List::stream).toList(), content); + } else { + assertEquals(discardStderr(), result.findStdout().isPresent()); + assertTrue(result.findStderr().isEmpty()); + if (contains(OutputControl.SAVE_FIRST_LINE)) { + assertTrue(List.of(command.stdout(), command.stderr()).contains(result.content()), + String.format("Saved content %s is either %s or %s", + content, command.stdout(), command.stderr())); + } else if (contains(OutputControl.SAVE_ALL)) { + if (!isInterleave(content, command.stdout(), command.stderr())) { + fail(String.format("Unexpected combined saved content=%s; stdout=%s; stderr=%s", + content, command.stdout(), command.stderr())); + } + } else { + // Unreachable + throw ExceptionBox.reachedUnreachable(); + } + } + } + + private void verifyByteResultContent(CommandOutputControl.Result result, Command command, Charset charset) { + Objects.requireNonNull(result); + Objects.requireNonNull(command); + Objects.requireNonNull(charset); + + assertTrue(result.findContent().isEmpty()); + assertTrue(result.findStdout().isEmpty()); + assertTrue(result.findStderr().isEmpty()); + + if (!saveOutput()) { + assertTrue(result.findByteContent().isEmpty()); + assertTrue(result.findByteStdout().isEmpty()); + assertTrue(result.findByteStderr().isEmpty()); + return; + } + + assertTrue(result.findByteContent().isPresent()); + + command = filterSavedStreams(command); + + if (!redirectStderr()) { + assertEquals(command.stdout(), toStringList(result.byteStdout(), charset)); + assertEquals(command.stderr(), toStringList(result.byteStderr(), charset)); + assertEquals(Stream.of( + command.stdout(), + command.stderr() + ).flatMap(List::stream).toList(), toStringList(result.byteContent(), charset)); + } else { + assertEquals(discardStderr(), result.findByteStdout().isPresent()); + assertTrue(result.findByteStderr().isEmpty()); + + var combined = toStringList(result.byteContent(), charset); + if (!isInterleave(combined, command.stdout(), command.stderr())) { + fail(String.format("Unexpected combined saved content=%s; stdout=%s; stderr=%s", + combined, command.stdout(), command.stderr())); + } + } + } + + private List expectedSavedStream(List commandOutput) { + Objects.requireNonNull(commandOutput); + if (contains(OutputControl.SAVE_ALL) || (contains(OutputControl.SAVE_FIRST_LINE) && contains(OutputControl.BINARY_OUTPUT))) { + return commandOutput; + } else if (contains(OutputControl.SAVE_FIRST_LINE)) { + return commandOutput.stream().findFirst().map(List::of).orElseGet(List::of); + } else { + throw new IllegalStateException(); + } + } + + private Command filterSavedStreams(Command command) { + return new Command( + (discardStdout() ? List.of() : expectedSavedStream(command.stdout())), + (discardStderr() ? List.of() : expectedSavedStream(command.stderr()))); + } + + private record DumpCapture(byte[] out, byte[] err, Charset outCharset, Charset errCharset) { + DumpCapture { + Objects.requireNonNull(out); + Objects.requireNonNull(err); + Objects.requireNonNull(outCharset); + Objects.requireNonNull(errCharset); + } + + List outLines() { + return toStringList(out, outCharset); + } + + List errLines() { + return toStringList(err, errCharset); + } + + static DumpCapture captureDump(Runnable runnable) { + final var captureOut = new ByteArrayOutputStream(); + final var captureErr = new ByteArrayOutputStream(); + + final var out = System.out; + final var err = System.err; + try { + final var outCharset = System.out.charset(); + final var errCharset = System.err.charset(); + System.setOut(new PrintStream(captureOut, true, outCharset)); + System.setErr(new PrintStream(captureErr, true, errCharset)); + runnable.run(); + return new DumpCapture(captureOut.toByteArray(), captureErr.toByteArray(), outCharset, errCharset); + } finally { + try { + System.setOut(out); + } finally { + System.setErr(err); + } + } + } + } + + private CommandOutputControl.Executable createExecutable(Command command) { + final var coc = cocSpec.create(); + if (toolProvider) { + return coc.createExecutable(command.asToolProvider()); + } else { + return coc.createExecutable(new ProcessBuilder(command.asExecutable())); + } + } + } + + record CharsetTestSpec(boolean toolProvider, CommandOutputControlSpec cocSpec) { + + void test() throws IOException, InterruptedException { + if (cocSpec.outputControl().stream().noneMatch(DumpOutputSink.class::isInstance)) { + throw new IllegalArgumentException(); + } + + final var expectedString = "veni-vidi-vici"; + + var coc = cocSpec.create().dumpOutput(true); + + CommandOutputControl.Executable exec; + if (toolProvider) { + var tp = Command.createToolProvider(Stream.of(expectedString).mapMulti((str, sink) -> { + sink.accept(CommandAction.echoStdout(str)); + sink.accept(CommandAction.echoStderr(str)); + }).toList()); + exec = coc.createExecutable(tp); + } else { + var cmdline = Command.createShellCommandLine(Stream.of(expectedString).map(str -> { + return (str + System.lineSeparator()).getBytes(coc.charset()); + }).mapMulti((bytes, sink) -> { + sink.accept(CommandAction.writeStdout(bytes)); + sink.accept(CommandAction.writeStderr(bytes)); + }).toList()); + exec = coc.createExecutable(new ProcessBuilder(cmdline)); + } + + exec.execute(); + + for (var outputContolMutator : cocSpec.outputControl()) { + if (outputContolMutator instanceof DumpOutputSink sink) { + var actual = sink.lines(); + List expected; + if (cocSpec.redirectStderr()) { + switch (sink.streams()) { + case STDERR -> { + expected = List.of(); + } + default -> { + expected = List.of(expectedString, expectedString); + } + } + } else { + expected = List.of(expectedString); + } + assertEquals(expected, actual); + } + } + + } + + record DumpOutputSink(Charset charset, ByteArrayOutputStream buffer, OutputStreams streams) implements CommandOutputControlMutator { + DumpOutputSink { + Objects.requireNonNull(charset); + Objects.requireNonNull(buffer); + Objects.requireNonNull(streams); + } + + DumpOutputSink(Charset charset, OutputStreams streams) { + this(charset, new ByteArrayOutputStream(), streams); + } + + List lines() { + var str = buffer.toString(charset); + return new BufferedReader(new StringReader(str)).lines().toList(); + } + + @Override + public String name() { + return String.format("DUMP-%s-%s", streams, charset.name()); + } + + @Override + public void mutate(CommandOutputControl coc) { + var ps = new PrintStream(buffer, false, charset); + switch (streams) { + case STDOUT -> { + coc.dumpStdout(ps); + } + case STDERR -> { + coc.dumpStderr(ps); + } + case STDOUT_AND_STDERR -> { + // Easy to implement, but not used. + throw new IllegalArgumentException(); + } + default -> { + // Unreachable + throw ExceptionBox.reachedUnreachable(); + } + } + } + } + } + + private final static class InterruptibleToolProvider implements ToolProvider { + + InterruptibleToolProvider(ToolProvider impl) { + this.impl = impl; + } + + @Override + public String name() { + return impl.name(); + } + + @Override + public int run(PrintStream out, PrintStream err, String... args) { + return run(_ -> { + return impl.run(out, err, args); + }, args); + } + + @Override + public int run(PrintWriter out, PrintWriter err, String... args) { + return run(_ -> { + return impl.run(out, err, args); + }, args); + } + + boolean interrupted() { + return interrupted.join(); + } + + private int run(Function workload, String... args) { + boolean interruptedValue = false; + try { + return workload.apply(args); + } catch (ExceptionBox ex) { + if (ex.getCause() instanceof InterruptedException) { + interruptedValue = true; + return 1; + } else { + throw ex; + } + } finally { + interrupted.complete(interruptedValue); + } + } + + private final ToolProvider impl; + private final CompletableFuture interrupted = new CompletableFuture<>(); + } + + private static List toStringList(byte[] data, Charset charset) { + try (var bufReader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(data), charset))) { + return bufReader.lines().toList(); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static List> withAndWithout (T value) { + return List.of(Optional.empty(), Optional.of(value)); + } + + private static final List BOOLEAN_VALUES = List.of(true, false); +} diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/CommandOutputControlTestUtils.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/CommandOutputControlTestUtils.java new file mode 100644 index 00000000000..dec0c5d6b0a --- /dev/null +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/CommandOutputControlTestUtils.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class CommandOutputControlTestUtils { + + @ParameterizedTest + @MethodSource + public void test_isInterleave(TestSpec test) { + test.run(); + } + + private static Stream test_isInterleave() { + var data = new ArrayList(); + + data.addAll(List.of( + interleaved("Toaday", "Today", "a"), + interleaved("Todanaay", "Today", "ana"), + interleaved("aaaababaaa", "aaaba", "aabaa"), + interleaved("xxxxxxxxxxxyxxyx", "xxxxxxxy", "xxxxxyxx"), + interleaved("xyxxxxyxxxxxxxxx", "yxxxxxxx", "xxxyxxxx"), + interleaved("xxxxxxxyxxxxyxxx", "xxxyxxxx", "xxxxxxyx"), + interleaved("cbdddcdaadacdbddbdcdddccdabbadba", "cdddaaddbcdcdbab", "bdcadcbddddcabda"), + interleaved("ddbdcacddddbddbdbddadcaaccdcabab", "dbccdddbbddacdaa", "ddaddbdddacaccbb"), + interleaved("adccbacbacaacddadddcdbbddbbddddd", "acbcaacddddbdbdd", "dcabcadadcbdbddd"), + interleaved("abdbdabdaacdcdbddddadbbccddcddac", "addbaccbdddbcdda", "bbadaddddabcdcdc"), + interleaved("cdaacbddaabdddbddbddbddadbacccdc", "dabdadddbddabccc", "cacdabdbddbddacd"), + notInterleaved("Toady", "Today", "a"), + notInterleaved("", "Today", "a") + )); + + data.addAll(generateTestData("abcdefghijklmnopqrstuvwxyz", 10)); + data.addAll(generateTestData("xxxxxxxy", 8)); + data.addAll(generateTestData("aaabbbcccddddddd", 50)); + + return data.stream().flatMap(test -> { + return Stream.of(test, test.flip()); + }); + } + + private static List generateTestData(String src, int iteration) { + + var srcCodePoints = new ArrayList(); + src.codePoints().mapToObj(Integer::valueOf).forEach(srcCodePoints::add); + + var data = new ArrayList(); + + Function, String> toString = codePoints -> { + var arr = codePoints.stream().mapToInt(Integer::intValue).toArray(); + return new String(arr, 0, arr.length); + }; + + for (int i = 0; i < 10; i++) { + Collections.shuffle(srcCodePoints); + var a = List.copyOf(srcCodePoints); + + Collections.shuffle(srcCodePoints); + var b = List.copyOf(srcCodePoints); + + var zip = new int[srcCodePoints.size() * 2]; + for (int codePointIdx = 0; codePointIdx != a.size(); codePointIdx++) { + var dstIdx = codePointIdx * 2; + zip[dstIdx] = a.get(codePointIdx); + zip[dstIdx + 1] = b.get(codePointIdx); + } + + data.add(interleaved(toString.apply(Arrays.stream(zip).boxed().toList()), toString.apply(a), toString.apply(b))); + } + + return data; + } + + public record TestSpec(String combined, String a, String b, boolean expected) { + + public TestSpec { + Objects.requireNonNull(combined); + Objects.requireNonNull(a); + Objects.requireNonNull(b); + } + + TestSpec flip() { + return new TestSpec(combined, b, a, expected); + } + + void run() { + assertEquals(expected, isInterleave( + combined.chars().mapToObj(Integer::valueOf).toList(), + a.chars().mapToObj(Integer::valueOf).toList(), + b.chars().mapToObj(Integer::valueOf).toList()), + String.format("combined: %s; a=%s; b=%s", combined, a, b)); + } + } + + private static TestSpec interleaved(String combined, String a, String b) { + return new TestSpec(combined, a, b, true); + } + + private static TestSpec notInterleaved(String combined, String a, String b) { + return new TestSpec(combined, a, b, false); + } + + // Solves the standard "Find if a string C is an interleave of strings A and B." + // problem but use containers instead of strings. + static boolean isInterleave(List combined, List a, List b) { + + if (a.size() + b.size() != combined.size()) { + return false; + } + + final var n = a.size(); + final var m = b.size(); + + var prev = new boolean[m + 1]; + final var cur = new boolean[m + 1]; + + prev[0] = true; + + for (int j = 1; j <= m; j++) { + prev[j] = prev[j - 1] && Objects.equals(b.get(j - 1), combined.get(j - 1)); + } + + for (int i = 1; i <= n; i++) { + cur[0] = prev[0] && Objects.equals(a.get(i - 1), combined.get(i - 1)); + + for (int j = 1; j <= m; j++) { + int k = i + j; + cur[j] = (prev[j] && Objects.equals(a.get(i - 1), combined.get(k - 1))) + || (cur[j - 1] && Objects.equals(b.get(j - 1), combined.get(k - 1))); + } + + prev = cur.clone(); + } + + return prev[m]; + } +} diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/EnquoterTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/EnquoterTest.java similarity index 57% rename from test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/EnquoterTest.java rename to test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/EnquoterTest.java index 95b0e54a0cd..8dc44f3f2fd 100644 --- a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/EnquoterTest.java +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/EnquoterTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2022, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -21,11 +21,13 @@ * questions. */ -package jdk.jpackage.internal; +package jdk.jpackage.internal.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.stream.Stream; -import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -45,27 +47,41 @@ public class EnquoterTest { assertEquals(expected, actual); } - private static Stream testForShellLiterals() { + @ParameterizedTest + @MethodSource + public void testIdentity(String input) { + var actual = Enquoter.identity().applyTo(input); + assertEquals(input, actual); + } + + @ParameterizedTest + @MethodSource("testIdentity") + public void testNoEscaper(String input) { + var actual = Enquoter.identity().setEnquotePredicate(_ -> true).applyTo(input); + assertEquals('"' + input + '"', actual); + } + + private static Stream testForShellLiterals() { return Stream.of( - makeArguments("''", ""), - makeArguments("'foo'", "foo"), - makeArguments("' foo '", " foo "), - makeArguments("'foo bar'", "foo bar"), - makeArguments("'foo\\' bar'", "foo' bar") + Arguments.of("''", ""), + Arguments.of("'foo'", "foo"), + Arguments.of("' foo '", " foo "), + Arguments.of("'foo bar'", "foo bar"), + Arguments.of("'foo\\' bar'", "foo' bar") ); } - private static Stream testForPropertyValues() { + private static Stream testForPropertyValues() { return Stream.of( - makeArguments("", ""), - makeArguments("foo", "foo"), - makeArguments("\" foo \"", " foo "), - makeArguments("\"foo bar\"", "foo bar"), - makeArguments("\"foo' bar\"", "foo' bar") + Arguments.of("", ""), + Arguments.of("foo", "foo"), + Arguments.of("\" foo \"", " foo "), + Arguments.of("\"foo bar\"", "foo bar"), + Arguments.of("\"foo' bar\"", "foo' bar") ); } - static org.junit.jupiter.params.provider.Arguments makeArguments(Object ... args) { - return org.junit.jupiter.params.provider.Arguments.of(args); + private static Stream testIdentity() { + return Stream.of("", "foo", " foo ", "foo bar", "foo' bar"); } } diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/RetryExecutorTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/RetryExecutorTest.java new file mode 100644 index 00000000000..abb0363da80 --- /dev/null +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/RetryExecutorTest.java @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal.util; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.time.Duration; +import java.util.Objects; +import jdk.jpackage.internal.util.RetryExecutor.Context; +import jdk.jpackage.internal.util.function.ExceptionBox; +import jdk.jpackage.internal.util.function.ThrowingFunction; +import jdk.jpackage.internal.util.function.ThrowingSupplier; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class RetryExecutorTest { + + @Test + public void test_defaults() { + + var executor = new AttemptCounter(context -> { + throw new AttemptFailedException(); + }); + + var defaultTimeout = Duration.ofSeconds(2); + var defaultAttemptCount = 5; + + var timeout = Slot.createEmpty(); + + assertThrowsExactly(AttemptFailedException.class, new RetryExecutor(Exception.class) + .setExecutable(executor) + .setSleepFunction(t -> { + assertEquals(defaultTimeout, t); + timeout.set(t); + return; + })::execute); + + assertEquals(defaultTimeout, timeout.get()); + assertEquals(defaultAttemptCount, executor.count()); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3, -4}) + public void test_N_attempts_fail(int maxAttemptsCount) throws AttemptFailedException { + + var retry = new RetryExecutor(AttemptFailedException.class) + .setMaxAttemptsCount(maxAttemptsCount) + .setAttemptTimeout(null) + .setExecutable(context -> { + if (context.attempt() == (maxAttemptsCount - 1)) { + assertTrue(context.isLastAttempt()); + } else { + assertFalse(context.isLastAttempt()); + } + throw new AttemptFailedException("Attempt: " + context.attempt()); + }); + + if (maxAttemptsCount <= 0) { + assertNull(retry.execute()); + } else { + var ex = assertThrowsExactly(AttemptFailedException.class, retry::execute); + assertEquals("Attempt: " + (maxAttemptsCount - 1), ex.getMessage()); + } + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3}) + public void test_N_attempts_last_succeed(int maxAttemptsCount) throws AttemptFailedException { + test_N_attempts_M_succeed(maxAttemptsCount, maxAttemptsCount - 1, false); + } + + @ParameterizedTest + @ValueSource(ints = {2, 3}) + public void test_N_attempts_first_succeed(int maxAttemptsCount) throws AttemptFailedException { + test_N_attempts_M_succeed(maxAttemptsCount, 0, false); + } + + @Test + public void test_N_attempts_2nd_succeed() throws AttemptFailedException { + test_N_attempts_M_succeed(4, 1, false); + } + + @Test + public void test_N_attempts_2nd_succeed_unchecked() throws AttemptFailedException { + test_N_attempts_M_succeed(4, 1, true); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_null_executor(boolean dynamic) { + var retry = new RetryExecutor(AttemptFailedException.class) + .setAttemptTimeout(null).setMaxAttemptsCount(1000); + + if (dynamic) { + int maxAttemptsCount = 3; + var executor = new AttemptCounter(context -> { + assertTrue(context.attempt() <= (maxAttemptsCount - 1)); + if (context.attempt() == (maxAttemptsCount - 1)) { + context.executor().setExecutable((ThrowingSupplier)null); + } + throw new AttemptFailedException("foo"); + }); + + retry.setExecutable(executor); + + var ex = assertThrowsExactly(IllegalStateException.class, retry::execute); + assertEquals("No executable", ex.getMessage()); + assertEquals(3, executor.count()); + } else { + var ex = assertThrowsExactly(IllegalStateException.class, retry::execute); + assertEquals("No executable", ex.getMessage()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_unexpected_exception(boolean executeUnchecked) { + var cause = new UnsupportedOperationException("foo"); + + var executor = new AttemptCounter(context -> { + assertEquals(0, context.attempt()); + throw cause; + }); + + var retry = new RetryExecutor(IOException.class).setExecutable(executor) + .setMaxAttemptsCount(10).setAttemptTimeout(null); + + UnsupportedOperationException ex; + if (executeUnchecked) { + ex = assertThrowsExactly(UnsupportedOperationException.class, retry::executeUnchecked); + } else { + ex = assertThrowsExactly(UnsupportedOperationException.class, retry::execute); + } + assertSame(cause, ex); + assertEquals(1, executor.count()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_dynamic(boolean abort) { + int maxAttemptsCount = 4; + + var secondExecutor = new AttemptCounter(context -> { + throw new AttemptFailedException("bar"); + }); + + var firstExecutor = new AttemptCounter(context -> { + assertTrue(context.attempt() <= (maxAttemptsCount - 1)); + if (context.attempt() == (maxAttemptsCount - 1)) { + if (abort) { + context.executor().setMaxAttemptsCount(maxAttemptsCount); + } else { + // Let it go two more times. + context.executor().setMaxAttemptsCount(maxAttemptsCount + 2); + } + context.executor().setExecutable(secondExecutor); + } + throw new AttemptFailedException("foo"); + }); + + var retry = new RetryExecutor(AttemptFailedException.class) + .setExecutable(firstExecutor) + .setMaxAttemptsCount(1000000) + .setAttemptTimeout(null); + + var ex = assertThrowsExactly(AttemptFailedException.class, retry::execute); + if (abort) { + assertEquals("foo", ex.getMessage()); + assertEquals(0, secondExecutor.count()); + } else { + assertEquals("bar", ex.getMessage()); + assertEquals(2, secondExecutor.count()); + } + assertEquals(maxAttemptsCount, firstExecutor.count()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_supplier_executor(boolean isNull) throws Exception { + var retry = new RetryExecutor(Exception.class).setMaxAttemptsCount(1); + if (isNull) { + retry.setExecutable((ThrowingSupplier)null); + var ex = assertThrowsExactly(IllegalStateException.class, retry::execute); + assertEquals("No executable", ex.getMessage()); + } else { + retry.setExecutable(() -> "Hello"); + assertEquals("Hello", retry.execute()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_executeUnchecked_fail(boolean withExceptionMapper) throws AttemptFailedException { + var retry = new RetryExecutor(AttemptFailedException.class).setExecutable(() -> { + throw new AttemptFailedException("kaput!"); + }).setMaxAttemptsCount(1); + + Class expectedExceptionType; + if (withExceptionMapper) { + retry.setExceptionMapper((AttemptFailedException ex) -> { + assertEquals("kaput!", ex.getMessage()); + return new UncheckedAttemptFailedException(ex); + }); + expectedExceptionType = UncheckedAttemptFailedException.class; + } else { + expectedExceptionType = ExceptionBox.class; + } + + var ex = assertThrowsExactly(expectedExceptionType, retry::executeUnchecked); + assertEquals(AttemptFailedException.class, ex.getCause().getClass()); + assertEquals("kaput!", ex.getCause().getMessage()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void test_setSleepFunction(boolean withTimeout) { + + var timeout = Slot.createEmpty(); + + assertDoesNotThrow(new RetryExecutor(AttemptFailedException.class) + .setMaxAttemptsCount(2) + .mutate(retry -> { + if (withTimeout) { + retry.setAttemptTimeout(Duration.ofDays(100)); + } else { + retry.setAttemptTimeout(null); + } + }) + .setExecutable(context -> { + if (context.isLastAttempt()) { + return null; + } else { + throw new AttemptFailedException(); + } + }) + .setSleepFunction(timeout::set)::execute); + + assertEquals(withTimeout, timeout.find().isPresent()); + if (withTimeout) { + assertEquals(Duration.ofDays(100), timeout.get()); + } + } + + private static void test_N_attempts_M_succeed(int maxAttempts, int failedAttempts, boolean unchecked) throws AttemptFailedException { + + var countingExecutor = new AttemptCounter(context -> { + if (context.attempt() == failedAttempts) { + return "You made it!"; + } else { + throw new AttemptFailedException(); + } + }); + + var retry = new RetryExecutor(AttemptFailedException.class) + .setMaxAttemptsCount(maxAttempts) + .setAttemptTimeout(null) + .setExecutable(countingExecutor); + + assertEquals("You made it!", unchecked ? retry.execute() : retry.executeUnchecked()); + assertEquals(failedAttempts, countingExecutor.count() - 1); + } + + private static final class AttemptCounter implements ThrowingFunction>, T, E> { + + AttemptCounter(ThrowingFunction>, T, E> impl) { + this.impl = Objects.requireNonNull(impl); + } + + @Override + public T apply(Context> context) throws E { + counter++; + return impl.apply(context); + } + + int count() { + return counter; + } + + private int counter; + private final ThrowingFunction>, T, E> impl; + } + + private static final class AttemptFailedException extends Exception { + + AttemptFailedException(String msg) { + super(msg); + } + + AttemptFailedException() { + } + + private static final long serialVersionUID = 1L; + } + + private static final class UncheckedAttemptFailedException extends RuntimeException { + + UncheckedAttemptFailedException(AttemptFailedException ex) { + super(ex); + } + + private static final long serialVersionUID = 1L; + } +} diff --git a/test/jdk/tools/jpackage/share/ErrorTest.java b/test/jdk/tools/jpackage/share/ErrorTest.java index bc12577fa20..ca1189a191e 100644 --- a/test/jdk/tools/jpackage/share/ErrorTest.java +++ b/test/jdk/tools/jpackage/share/ErrorTest.java @@ -441,10 +441,7 @@ public final class ErrorTest { .error("error.no-module-in-path", "com.foo.bar"), // non-existing argument file testSpec().noAppDesc().notype().addArgs("@foo") - .error("ERR_CannotParseOptions", "foo"), - // invalid jlink option - testSpec().addArgs("--jlink-options", "--foo") - .error("error.jlink.failed", "Error: unknown option: --foo") + .error("ERR_CannotParseOptions", "foo") ).map(TestSpec.Builder::create).toList()); // --main-jar and --module-name diff --git a/test/jdk/tools/jpackage/share/PostImageScriptTest.java b/test/jdk/tools/jpackage/share/PostImageScriptTest.java index 655658f036d..81cdfffbf46 100644 --- a/test/jdk/tools/jpackage/share/PostImageScriptTest.java +++ b/test/jdk/tools/jpackage/share/PostImageScriptTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -229,7 +229,7 @@ public class PostImageScriptTest { cmd.saveConsoleOutput(true); }).addBundleVerifier((cmd, result) -> { - final var imageDir = result.stdout().getOutput().stream().map(String::stripLeading).filter(str -> { + final var imageDir = result.stdout().stream().map(String::stripLeading).filter(str -> { return str.startsWith(imageDirOutputPrefix); }).map(str -> { return str.substring(imageDirOutputPrefix.length());