Skip to content

Conversation

straight-shoota
Copy link
Member

@straight-shoota straight-shoota commented Jul 23, 2025

We're interpreting error codes from the operating system all over the file and process implementations, as well as when instantiating File::Error.
This patch merges them together so we always map the same set of error codes to specific error classes (not found, access denied etc.).

Resolves #15905
Closes #15902

@HertzDevil
Copy link
Contributor

Could these be plain methods instead? I'd prefer not having to use Arrays of unions for this

Comment on lines 51 to 67
# :nodoc:
# See https://github.com/crystal-lang/crystal/issues/15905#issuecomment-2975820840
OS_ERRORS = [
Errno::ENAMETOOLONG,
Errno::ENOENT,
Errno::ENOTDIR,
WinError::ERROR_BAD_NETPATH,
WinError::ERROR_BAD_NET_NAME,
WinError::ERROR_BAD_PATHNAME,
WinError::ERROR_DIRECTORY,
WinError::ERROR_FILE_NOT_FOUND,
WinError::ERROR_FILENAME_EXCED_RANGE,
WinError::ERROR_INVALID_DRIVE,
WinError::ERROR_INVALID_NAME,
WinError::ERROR_PATH_NOT_FOUND,
WinError::WSAENAMETOOLONG,
]
Copy link
Member Author

Choose a reason for hiding this comment

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

thought: I'm not entirely sure about these OS_ERRORS arrays. It good that they are strongly connected to the respective error classes.
But there are some issues, for example searching in an array is not ideal for performance. For example, splatting the array into a when expression might be better. But it's also less ergonomical.
The WinError values could never match on unix targets, which could be another approach for improval.

I have considered alternative, but it's unclear whether they would be better:

  • A class method (.os_error?(error)) that returns true if the parameter matches one of the error codes. This is a less declarative approach. But might be more efficient?
  • Group predicate methods on Errno and WinError. Would be easy to implement, but more detached from the error classes. We could consider using the same name as the error classes, though (e.g. #not_found_error?, #access_denied_error?).

Copy link
Contributor

Choose a reason for hiding this comment

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

The class method body could be a case-expression generated from such an array with a macro, but at that point it isn't much different from not using a macro.

Copy link
Contributor

Choose a reason for hiding this comment

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

Why arrays and not tuples? Tuples have better performance and can be splatted into when expressions.

Copy link
Member Author

Choose a reason for hiding this comment

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

Arrays can also be splatted into when expressions. There's no reason for these arrays to exist at runtime. All use cases could be splatted at compile time.
It's just not very convenient to do so because we would need to wrap the entire case in {% begin %}...{% end %}.

I'll rewrite into class methods to see how that looks.

@straight-shoota straight-shoota force-pushed the feat/filename-too-long-error branch from c689756 to e0098dd Compare July 23, 2025 16:10
@straight-shoota straight-shoota added this to the 1.18.0 milestone Aug 4, 2025
WinError::ERROR_PATH_NOT_FOUND,
WinError::WSAENAMETOOLONG,
)
end
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't like this. We shall keep the errno / winerror checks buried under Crystal::System.

  • it's leaking system specifics out of crystal/system;
  • it creates an indirection when we want to know which errno or winerror will lead to an exception;
  • it's pushing target-specifics constants to every target, so POSIX targets have to check errno against all the winerror that will never match (the opposite for windows).

Copy link
Contributor

Choose a reason for hiding this comment

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

I like the explicit exceptions. I just don't like the #os_error? methods.

Copy link
Contributor

Choose a reason for hiding this comment

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

Said differently: each system implementation decides what exception to raise, it shouldn't have to ask each exception "hey, should I raise you?"

Copy link
Member Author

@straight-shoota straight-shoota Aug 5, 2025

Choose a reason for hiding this comment

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

Neither Errno nor WinError nor File::Error are in Crystal::System. So it feels like this doesn't need to belong there either.

We could consider moving these methods into Crystal::System. That breaks the direct connection to the error types. But I suppose it's not essential. I'm fine either way.

I suppose we could exclude checks against error codes that are impossible on the target system.
This would only mean putting the WinErrror codes behind {% if flag?(:win32) %} because some Windows API functions use errno, so on Windows we need all.

Copy link
Member Author

Choose a reason for hiding this comment

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

The question is not "should I raise you?" but "I got this error, does it fit the error class you describe?". And then proceed with either raising that error (which File::Error.from_os_error handles) or reacting to that error condition in a specific way.

Copy link
Contributor

Choose a reason for hiding this comment

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

If we want to eliminate the cross-type checks unless the argument is indeed a union between the system error code types, maybe one of these would work:

class File::Error
  def self.os_error?(error : Errno | WinError | WasiError)
    false
  end
end

class File::AlreadyExistsError < File::Error
  def self.os_error?(error : Errno)
    error == Errno::EEXIST
  end

  def self.os_error?(error : WinError)
    error.in?(
      WinError::ERROR_ALREADY_EXISTS,
      WinError::ERROR_FILE_EXISTS,
    )
  end
end
enum Errno
  def os_error_for?(ex : File::AlreadyExistsError.class)
    self == EEXIST
  end

  def os_error_for?(ex : File::Error.class)
    false
  end
end

enum WinError
  def os_error_for?(ex : File::AlreadyExistsError.class)
    self.in?(
      ERROR_ALREADY_EXISTS,
      ERROR_FILE_EXISTS,
    )
  end

  def os_error_for?(ex : File::Error.class)
    false
  end
end

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, I'm trying my best, but the more I look into the PR the less I understand how replacing a few errno checks after a syscall with an out-of-scope method call is supposed to be better. For example 🫤

Copy link
Member Author

@straight-shoota straight-shoota Aug 8, 2025

Choose a reason for hiding this comment

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

The main problem is that the current checks are incomplete. #15905 lists which error codes should usually be interpreted as "file not found". Currently, we're missing out on some of them in practically every instance. For example, the referenced example is missing ENOTDIR and ENAMETOOLONG.
On Windows we're generally even missing more than on Unix because Windows has more different codes.

The first step to fix that would be to add all the missing error codes in all the places where they're missing.
In system/win32 we're currently checking for error codes relating to File::NotFoundError in four different places. Each time we'd need to compare against ten different error codes, as identified in #15902.

That's a lot of duplication checking exactly the same set of values all over the place. And the individual error check conditions would be big and ugly to read. So IMO giving this common behaviour a name and extracting them into a shared helper is a good idea.

How exactly we implement that is up for debate.

@ysbaddaden ysbaddaden dismissed their stale review August 8, 2025 18:36

I'm dismissing my review to not block the PR, but I'm still not fond of these indirections; direct errno/winerror checks after a syscall keep things linked together, and there aren't so many of them that we really need to refactor.

@straight-shoota straight-shoota removed this from the 1.18.0 milestone Aug 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Path too long errors should be considered as File::NotFoundError
4 participants