Skip to content
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

gh-89727: Fix os.fwalk RecursionError on deep trees #100347

Closed
wants to merge 31 commits into from

Conversation

jonburdo
Copy link
Contributor

@jonburdo jonburdo commented Dec 19, 2022

Use a stack to implement os.fwalk iteratively instead of recursively to avoid hitting recursion limits on deeply nested trees.

Similar to how this is done for os.walk in #99803

@bedevere-bot
Copy link

Most changes to Python require a NEWS entry.

Please add it using the blurb_it web app or the blurb command-line tool.

@bedevere-bot
Copy link

Most changes to Python require a NEWS entry.

Please add it using the blurb_it web app or the blurb command-line tool.

@jonburdo
Copy link
Contributor Author

jonburdo commented Dec 19, 2022

TODO:

Lib/os.py Outdated Show resolved Hide resolved
@AlexWaygood AlexWaygood removed request for a team and 1st1 December 20, 2022 15:13
@gpshead gpshead self-requested a review December 24, 2022 02:45
@jonburdo jonburdo marked this pull request as draft January 4, 2023 22:56
Make sure file descriptors get closed if an exception occurs right after the file descriptor was popped from the stack. Also catch exceptions thrown by calling os.close on an already-closed file-descriptor when doing file descriptor cleanup.
@arhadthedev arhadthedev added the stdlib Python modules in the Lib dir label Mar 23, 2023
@jonburdo
Copy link
Contributor Author

jonburdo commented Mar 23, 2023

Note that handling of a single exception should function the same, but repeated errors will interrupt the file descriptor cleanup (closing).

Imagine a user holds down ctrl-c sending a burst of KeyboardExceptions. Previously in the recursive version there were nested try-finally statements making sure close(fd) gets called. I believe each exception might cause one of these close(fd) calls to be missed (if it occurs in the finally before close(fd) is executed), but after the burst of exceptions is over, the remaining calls would be made. Now the cleanup is handled in a loop, so a single exception would end the fd cleanup:

        try:
            ...
        except:
            for fd in reversed(fd_stack):
                try:
                    close(fd)
                except OSError:
                    pass
            raise

I'm not sure this is a problem. If I understand correctly, neither versions handles this perfectly. It seemed worth mentioning though.

I thought about changing the except OSError: is this cleanup loop to a bare except:, but then you get kind of a weird, inconsistent behavior where a KeyboardInterrupt that happens to fall just before the try-except starts will end the loop, but one that falls in the try-except won't.

@jonburdo jonburdo marked this pull request as ready for review March 23, 2023 15:16
@jonburdo jonburdo requested review from AlexWaygood and removed request for gpshead March 24, 2023 00:35
@jonburdo
Copy link
Contributor Author

Opening this up for review in response to #89727 (comment)

Lib/os.py Outdated Show resolved Hide resolved
If an exception occurred between `close(fd_stack[-1])` and `fd_stack.pop()`, then we would close the fd a second time in the except clause. It would be better to risk leaving the fd open, as the previous implementation of fwalk could
Copy link
Contributor

@barneygale barneygale left a comment

Choose a reason for hiding this comment

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

LGTM, nice work!

@barneygale
Copy link
Contributor

barneygale commented Mar 31, 2023

A thought: could you merge _fwalk() into fwalk()? As we're no longer calling _fwalk() recursively, the separation appears pointless, and the yield from indirection will have a small performance cost.

Lib/os.py Outdated
Comment on lines 563 to 570
except:
for action, value in reversed(stack):
if action is _WalkAction.CLOSE:
try:
close(value)
except OSError:
pass
raise
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this be a finally: block?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, would you consider that more readable or explicit? I just didn't see any benefit, since this is only needed if an exception occurs. The only functional difference would be that we'd iterate over an empty list if no exceptions occur right?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think I'd consider it more explicit, though feel free to wait for another opinion if you'd like.

Copy link
Member

Choose a reason for hiding this comment

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

I'd also prefer finally here. Not sure if it actually delivers any functional benefit here (can't remember the exact semantics off the top of my head...), but it does feel at least more readable to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, thanks for clarifying. I'll change it to finally. Functionally, I think finally is slightly worse but it's negligible.

In this case, changing to finally is functionally equivalent to adding an else clause with the same for-loop:

        try:
            ...
        except:
            for action, value in reversed(stack): ...
            raise
        else:
            for action, value in reversed(stack): ...

If no exception occurs in the try, then stack will be empty and all of the fds will have been closed already. So changing to finally means we're adding a step where we simply iterate over an empty list - unnecessary, but costs almost nothing.

To me, a bare except indicates a cleanup step, but finally is even more explicit, so it probably makes sense here.

@barneygale
Copy link
Contributor

@jonburdo I've fixed this in another PR - #119638. Shamefully I forgot about this PR :(.

Comparing our code, I think we arrived at similar solutions, the main difference being that I moved the lstat() of directory entries our of the scandir() loop. If there's anything you think I've missed in my implementation please let me know or log another PR and I'll review. Thanks and sorry.

@barneygale barneygale closed this Jun 1, 2024
@jonburdo
Copy link
Contributor Author

jonburdo commented Jun 2, 2024

@barneygale No worries! Thanks for mentioning it. I meant to follow up on this. I'll take a look at your PR.

Thanks for working on this function and the related ones! It feels great to see these improvements :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
awaiting merge stdlib Python modules in the Lib dir
Projects
None yet
Development

Successfully merging this pull request may close these issues.