Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,6 @@ def check_required_modules(self, additional_required_modules=None):
self.check_module("traceback")
self.check_module("os")
self.check_module("pickle")
self.check_module("imp")
self.check_module("types")
# Non-standard modules.
self.check_module("numpy")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ private static String getPythonKernelTesterPath() throws IOException {
if (url == null) {
throw new IOException("Could not locate the python kernel tester script");
}
return FileUtil.getFileFromURL(FileLocator.toFileURL(url)).getAbsolutePath();
return FileUtil.getFileFromURL(FileLocator.toFileURL(url)).getCanonicalPath();
}

private PythonKernelTester() {
Expand Down Expand Up @@ -149,22 +149,32 @@ private static synchronized PythonKernelTestResult testPythonInstallation(final
final var process = runPythonKernelTester(pythonCommand, majorVersion, minimumVersion,
additionalRequiredModules, additionalOptionalModules, testLogger);

// Get error output.
// Read stdout and stderr concurrently to avoid a deadlock: if Python fills
// one pipe buffer while Java blocks draining the other, both sides hang.
final var errorWriter = new StringWriter();
try (var err = process.getErrorStream()) {
IOUtils.copy(err, errorWriter, StandardCharsets.UTF_8);
final var outputWriter = new StringWriter();
final var stderrReader = new Thread(() -> {
try (var err = process.getErrorStream()) {
IOUtils.copy(err, errorWriter, StandardCharsets.UTF_8);
} catch (final IOException e) {
LOGGER.debug("Error reading stderr of Python kernel tester", e);
}
}, "PythonKernelTester-stderr");
stderrReader.start();
try (var in = process.getInputStream()) {
IOUtils.copy(in, outputWriter, StandardCharsets.UTF_8);
}
try {
stderrReader.join();
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
}
Comment on lines +156 to 171
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If reading stdout (IOUtils.copy(in, ...)) throws, the method exits via the outer catch (IOException ...) without joining the stderr thread or ensuring the process/streams are cleaned up, which can leave a background thread running and resources open. Consider joining the stderr reader in a finally block (and/or using an executor) so it is always terminated before returning, even on exceptions.

Copilot uses AI. Check for mistakes.

var errorOutput = errorWriter.toString();
if (!errorOutput.isEmpty()) {
testLogger.append("Error during execution: " + errorOutput + "\n");
errorOutput = decorateErrorOutputForKnownProblems(errorOutput);
}

// Get regular output.
final var outputWriter = new StringWriter();
try (var in = process.getInputStream()) {
IOUtils.copy(in, outputWriter, StandardCharsets.UTF_8);
}
final String testOutput = outputWriter.toString();
testLogger.append("Raw test output: \n" + testOutput + "\n");

Expand Down Expand Up @@ -223,7 +233,7 @@ private static Process runPythonKernelTester(final ExternalProcessProvider pytho
}
if (!additionalRequiredModules.isEmpty()) {
commandArguments.add("-m"); // Flag for additional modules.
additionalOptionalModules.forEach(moduleSpec -> commandArguments.add(moduleSpec.toString()));
additionalRequiredModules.forEach(moduleSpec -> commandArguments.add(moduleSpec.toString()));
}
if (!additionalOptionalModules.isEmpty()) {
commandArguments.add("-o");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
*/
package org.knime.python3;

import java.io.File;
import java.nio.file.Path;
import java.util.List;

import org.knime.externalprocessprovider.ExternalProcessProvider;
Expand Down Expand Up @@ -78,4 +80,52 @@ public SimplePythonCommand(final String... commands) {
public SimplePythonCommand(final List<String> command) {
super(command);
}

/**
* Creates a {@link ProcessBuilder} for this command, prepending the Python executable's conda environment
* directories to {@code PATH}. On Windows, conda/pixi environments require several sub-directories to be on
* {@code PATH} for proper process startup:
* <ul>
* <li>The env root (contains the Python DLL)</li>
* <li>{@code Library/bin} (contains vcruntime, openssl, and other SxS-manifest DLLs)</li>
* <li>{@code Library/mingw-w64/bin} (contains mingw toolchain DLLs)</li>
* <li>{@code Library/usr/bin} (contains misc tools)</li>
* <li>{@code Scripts} (contains pip, etc.)</li>
* </ul>
* Without these, Python 3.12+ fails at startup with a Windows SxS assembly error because VC++ runtime DLLs
* cannot be located. Only directories that actually exist on disk are prepended to avoid polluting {@code PATH}
* for non-conda environments.
*/
@Override
public ProcessBuilder createProcessBuilder() {
final var pb = super.createProcessBuilder();
final Path executableDir = getExecutablePath().getParent();
if (executableDir != null) {
final var env = pb.environment();
// ProcessBuilder.environment() on Windows stores keys case-insensitively but
// preserves the original casing. Find the existing key to avoid duplicates.
final String pathKey = env.keySet().stream() //
.filter(k -> k.equalsIgnoreCase("PATH")) //
.findFirst() //
.orElse("PATH");
final String existingPath = env.getOrDefault(pathKey, "");

// Standard conda/pixi activation paths for Windows, in activation order.
final var dirsToAdd = new StringBuilder();
for (final String subDir : new String[]{"", "Library\\bin", "Library\\mingw-w64\\bin",
"Library\\usr\\bin", "Scripts"}) {
final var candidate = subDir.isEmpty() ? executableDir : executableDir.resolve(subDir);
if (candidate.toFile().isDirectory()) {
Comment on lines +100 to +118
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createProcessBuilder() currently prepends PATH entries on all OSes whenever the executable path has a parent directory. The Javadoc/PR description says this is a Windows-only conda/pixi workaround; consider guarding with a Windows check (e.g., SystemUtils.IS_OS_WINDOWS) and only applying when a conda/pixi layout is detected, otherwise this can unnecessarily alter PATH for non-conda installs and on non-Windows platforms.

Copilot uses AI. Check for mistakes.
if (dirsToAdd.length() > 0) {
dirsToAdd.append(File.pathSeparator);
}
dirsToAdd.append(candidate);
}
}
if (dirsToAdd.length() > 0) {
env.put(pathKey, dirsToAdd + File.pathSeparator + existingPath);
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When existingPath is empty, env.put(pathKey, dirsToAdd + File.pathSeparator + existingPath) leaves a trailing path separator, which creates an empty PATH element. On Windows an empty PATH element can be interpreted as the current working directory, which is a security risk; build the new PATH so no empty segment is introduced (only append the separator + existingPath when it is non-empty).

Suggested change
env.put(pathKey, dirsToAdd + File.pathSeparator + existingPath);
final String newPath = existingPath.isEmpty()
? dirsToAdd.toString()
: dirsToAdd + File.pathSeparator + existingPath;
env.put(pathKey, newPath);

Copilot uses AI. Check for mistakes.
}
}
return pb;
}
}
Loading