Skip to content

Unify tests on all supported platforms to ensure consistent behavior and add more tests. #133

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

iCharlesHu
Copy link
Contributor

This PR refactors and consolidates tests across all platforms and introduces numerous additional tests.

Now, tests are categorized into three main types:

  • IntegrationTests: This serves as the primary test file that evaluates the end-to-end functionality of Subprocess by executing child processes.
  • {Unix|Darwin|Linux|Windows}Tests: These tests are platform-specific and cater to the unique characteristics of each operating system.
  • AsyncIOTests: These tests are dedicated to unit testing the AsyncIO functionality specifically.

These newly established test categories have revealed a few bugs in the implementation, which are also addressed in this PR:

  • Moved the monitorProcessTermination function inside the withAsyncTaskCleanupHandler. This adjustment ensures that the teardown sequence can still be executed even if the body closure terminates prematurely, but the child process remains stuck.
  • On Windows, we’ve modified the code to use STARTUPINFOEXW to ensure that only the explicitly provided file descriptors are inheritable.
  • Make sure AsyncIO.shutdown is idempotent.

Resolves: #97

@jakepetroules
Copy link
Contributor

jakepetroules commented Aug 8, 2025

On Windows, we’ve modified the code to use STARTUPINFOEXW to ensure that only the explicitly provided file descriptors are inheritable.

❤️

Resolves: #100

guard case .success(let currentState) = self.state else {
return
}

guard self.shutdownFlag.add(1, ordering: .sequentiallyConsistent).newValue == 1 else {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be >=1 instead of ==1? I think as written this will run the shutdown procedure n-1 times for n calls.

guard case .success(let ioPort) = ioCompletionPort,
case .success(let monitorThreadHandle) = monitorThread else {
return
}
// Make sure we don't shutdown the same instance twice
guard self.shutdownFlag.add(1, ordering: .relaxed).newValue == 1 else {
Copy link
Contributor

Choose a reason for hiding this comment

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

needs to be >=1?

_ body: (UnsafeMutablePointer<STARTUPINFOEXW>) throws -> Result
) rethrows -> Result {
var info: STARTUPINFOEXW = STARTUPINFOEXW()
info.StartupInfo.cb = DWORD(MemoryLayout<STARTUPINFOEXW>.size)
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: can use MemoryLayout.size(ofValue: info) to avoid repeating the type name (seems safer)

GetExitCodeProcess(self.processInformation.hProcess, &exitCode)
return exitCode == STILL_ACTIVE
#else
return kill(self.processIdentifier.value, 0) == 0
Copy link
Contributor

Choose a reason for hiding this comment

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

Any particular reason to use kill 0 instead of waitpid with WNOHANG+WNOWAIT?

@@ -194,6 +198,19 @@ extension Execution {
}
}
}

private func isStillAlive() -> Bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

Could already have exited by the time you return, perhaps a better name is "isPotentiallyStillAlive"? The only definitive case we can know is that it did exit.

arguments: ["/c", "set"],
environment: .custom([
"Path": "C:\\Windows\\system32;C:\\Windows",
"ComSpec": "C:\\Windows\\System32\\cmd.exe",
Copy link
Contributor

Choose a reason for hiding this comment

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

Can use this to compute the system32 directory:

private func _url(for id: KNOWNFOLDERID) -> URL? {
    var pszPath: PWSTR?
    let hr: HRESULT = withUnsafePointer(to: id) { id in
        SHGetKnownFolderPath(id, DWORD(WinSDK.KF_FLAG_DEFAULT.rawValue), nil, &pszPath)
    }
    guard hr >= 0 else { return nil }
    defer { CoTaskMemFree(pszPath) }
    return URL(filePath: String(decodingCString: pszPath!, as: UTF16.self), directoryHint: .isDirectory)
}

_url(for: FOLDERID_System) // C:\\Windows\\System32

var buffer: Data!
while let result = try await group.next() {
if let result: Data = result {
buffer = result
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
buffer = result
return result

buffer = result
}
}
return buffer
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return buffer
preconditionFailure("unreachable")

output: .string(limit: 16),
error: .discarded
)
Issue.record("Expected to throw")
Copy link
Contributor

Choose a reason for hiding this comment

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

For this you can use:

#expect(throws: Error.self) {
    // code...
}

let grandChildPid = try #require(pid_t(output))
// Make sure the grand child `/usr/bin/yes` actually exited
// This is unfortunately racy because the pid isn't immediately invalided
// once the exit exits. Allow a few failures and delay to counter this
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// once the exit exits. Allow a few failures and delay to counter this
// once `kill` returns. Allow a few failures and delay to counter this

?

try await withThrowingTaskGroup(of: Void.self) { group in
for _ in 0 ..< 8 {
group.addTask {
// This invocation specifically requires bash semantics; sh (on FreeBSD at least) does not consistently support -s in this way
Copy link
Contributor

Choose a reason for hiding this comment

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

Leftover comment? nothing here is using bash

@@ -226,7 +231,6 @@ final class AsyncIO: Sendable {
}
}


private func registerFileDescriptor(
Copy link
Contributor

Choose a reason for hiding this comment

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

Should registerFileDescriptor precondition that the fd is not already in the _registration map?

#expect(result.value == expected)
}

@Test func stressTestWithLittleOutput() async throws {
Copy link
Contributor

Choose a reason for hiding this comment

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

✘ Test stressTestWithLittleOutput() recorded an issue at IntegrationTests.swift:1460:6: Caught error: An error occurred within the AsyncIO subsystem: failed to add 27 to epoll list. Underlying error: UnderlyingError(rawValue: 17)

Could this be because _safelyClose does not call AsyncIO.shared.removeRegistration for the fd before closing it? Reading I/O fails because of low limit, fails, calls _safelyClose, fd is closed, fd # gets reused, tries to re-add, already in the epoll list, boom.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Handle inheritance should be constrained to specific handles on Windows? Unify unit tests on all platforms and add more
2 participants