Skip to content

Unclosed multipart S3File collected in a request-pool thread can block its cleanup #1364

Open

Description

@laughingman7743

Problem

An S3File opened for writing that is never closed is finalized by the garbage collector, which runs its cleanup in whatever thread triggers the collection. Since #1359, the parts of a multipart upload run in the request pool of the filesystem. Two cases can block that cleanup:

  1. The only thread of the pool. The collection runs in the only thread of the request pool (max_workers=1), while a part of the file is queued behind it. The cleanup waits for the queued part, which needs that thread.
  2. Inside its own running part. A cyclic, unclosed file is collected inside one of its own running part requests. Its finalizer waits for that request, which cannot finish until the finalizer returns. Cancelling the queued parts does not help here.

Both break T1 of the specification: no call in the request pool waits for a future of the request pool.

Open questions

  • Whether a collected unclosed file should commit, as it does now when the cleanup can run, or discard its upload. Cancelling the queued parts turns a commit into a discard.
  • Where the cleanup runs: inline in the finalizer thread for the parts that have not started (as _S3PoolScope._next does when the pool rejects a call), in an owned context outside the request pool, or deferred while a request of the file is running.

Context

Deferred from #1359 and from the step 3 design of #1332, where the first proposal (cancel the queued parts in the finalizer) was found insufficient by review.

Activity

  1. laughingman7743 commented on Nov 12, 2026

    @laughingman7743
    MemberAuthor

    Design (decided)

    Both cases reproduce with a stubbed client: the finalizer waits forever in commit() → _S3WriteSession.complete() → _complete_parts().

    Decisions on the open questions

    • Commit or discard. A collected unclosed file keeps today's behavior: it is closed as close() does, committing it unless autocommit=False. The one exception is a file whose write buffer the same collection has already finalized. Its data is incomplete, so it is discarded (its multipart upload is aborted), with a logged warning. Discarding every collected file, as AioS3StreamedFile does, would be a broader behavior change and is left out of this issue.

    • Where the cleanup runs. In a new thread, with an empty context, whenever waiting in the finalizer is unsafe:

      • inside a pass of the cyclic garbage collector, in any thread (tracked per thread with gc.callbacks), since the collecting thread may hold a lock of the pool, a scope, or a session, or be the request that the file waits for;
      • in a thread of any S3ThreadPool, also when the last reference is dropped there.

      A file whose last reference is dropped in a user thread outside a collection is still closed inline, so fs.open(path, "wb").write(data) still commits before the statement ends.

    Details

    • Before the close is handed over, the write buffer is copied. The collector finalizes every object of the garbage before it checks for resurrection, and IOBase finalization closes the original BytesIO. This was verified on CPython 3.10, 3.13, and 3.14: a thread started from __del__ finds the buffer closed. If the buffer is already closed when the finalizer runs, the file is discarded as above.
    • close() called during a collection by another finalizer, such as that of a TextIOWrapper in text mode, is handed over in the same way, and only once.
    • The finalizer does not inspect the scope or the session, since doing so would take their locks. Read files are handed over too.
    • If no thread can be started, as during interpreter finalization, the file is left unclosed, with a warning, and never closed inline. A thread started from a pool thread during threading._shutdown runs and is joined before the process exits (verified on 3.10–3.14). After ThreadPoolExecutor stops accepting calls, a final part cannot be submitted, so such a file is discarded instead of committed.
    • AioS3File, which inherits S3File, gets the same behavior. AioS3StreamedFile keeps its own finalizer.

    Limits (documented)

    • A file collected together with its filesystem and request pool: the collector clears the weak references of the pool's workers first, so its parts may never run. This is pre-existing, and the inline close blocks the same way.
    • An executor passed with executor= whose threads are not S3ThreadPool threads: a last reference dropped in one of them is closed inline.
    • A text-mode wrapper flushes its text into the file during the collection, before the close is handed over, and that flush can wait for the part window.

    Review

    Codex (static, against 68fc3be) found the buffer finalization, the lock taken by the pending-call check, collections in the cleanup thread, text wrappers, and custom executors. All are addressed or recorded above. The first idea, cancelling the queued parts in the finalizer, was rejected in #1332 because it does not cover the second case.

  2. laughingman7743 commented on Nov 12, 2026

    @laughingman7743
    MemberAuthor

    Addendum: interpreter finalization

    The decisions above are unchanged except for the bullet on files for which no thread can be started, which #1396 replaces:

    • During interpreter finalization, no thread is started, and the file is closed in the finalizing thread, as before. On Python 3.11, Thread.start() waits forever during finalization; on 3.12 and later, it raises. No other thread runs requests then, and the request pools take no more calls, so the close does not wait for a part queued behind it.
    • If a thread cannot be started otherwise, the file is left unclosed with a warning.
    • A file with data left to upload as a part at interpreter finalization is not committed, and its multipart upload is aborted: the pools take no more calls, or the collector has finalized its buffer first. This replaces the sentence about ThreadPoolExecutor, which named the wait at threading._shutdown rather than finalization.

    Checked with exit scripts and a stubbed client on CPython 3.11, 3.14, and 3.15:

    • A file whose last reference a pool thread drops during threading._shutdown is committed by the new thread, which runs and is joined.
    • An unclosed multipart file in a reference cycle that is collected at module teardown, with all of its parts sent, is committed on 3.11 and 3.14. On 3.15, its buffer is finalized before it, so it is discarded and its multipart upload aborted.
    • With data left to upload as a part, the same file is not committed, and its multipart upload is aborted: on 3.11 the part is rejected, and on 3.14 and 3.15 its buffer is finalized first.
    • At d03e034, each of these files that is not committed raised ValueError from its closed buffer instead, and its multipart upload was left incomplete.
  3. laughingman7743 commented on Nov 12, 2026

    @laughingman7743
    MemberAuthor

    Addendum 2: where and when a handed-over close runs, and later calls

    The independent reviews of #1396 led to these changes to the design above:

    • Order within a collection. A close handed over during a collection starts once the collection has ended: the closes are queued per thread and started by the gc stop callback. The collector may run the finalizer of the file before that of its TextIOWrapper, and a close started from the finalizer of the file raced with the wrapper writing its remaining text.
    • The thread. A handed-over close runs in a thread of the _thread module rather than a threading.Thread. Thread.start() takes locks of threading that the finalizing or collecting thread may hold (_active_limbo_lock on every version, held by threading.enumerate() while it allocates; _shutdown_locks_lock on 3.11 and 3.12, taken by the new thread before Thread.start() returns), so a start from a finalizer could wait forever. The interpreter does not join such threads, so an atexit handler waits for the handed-over closes; a fork clears them in the child, and a close queued in the parent is not run in a child.
    • Later calls. A later close(), commit(), or discard() of a file whose close was handed over waits for that close to end. Without the wait, a transaction could complete the upload without the data that the handed-over close uploads, and an explicit close could shut down the executor of the file during the handed-over close. During a collection or in a thread of an S3ThreadPool, where the wait could last forever (the end of the collection starts the close; the close may submit a part behind that pool thread), close() returns without waiting, and commit() and discard() raise RuntimeError.
    • No thread. A close for which no thread could be started is run by the next close(), commit(), or discard() of the file outside a collection and the pool threads, also one that already waits, rather than marked as ended, so that a transaction does not commit the file without the data that the close uploads; an exit does not wait for it.
    • Failed writes. A write that fails during the collection that handed over the close of its file, as a text wrapper writes when it is finalized, leaves the discard of the write to that close, which it cannot wait for.
    • After the exit wait. A close handed over once the exit has waited, such as in an exit handler registered before PyAthena was imported, is run in the finalizing thread, as during interpreter finalization.

    As a result, close() called during a collection, such as by a finalizer that writes a file in a with block, returns before the file is closed.

    Limits, in addition to those above:

    • A text wrapper whose last reference is dropped in a thread of an S3ThreadPool outside a collection closes its file in that thread, as before. No PyAthena pool call holds a text wrapper.
    • The write of the remaining text of a text wrapper finalized in a thread of an S3ThreadPool can wait forever for a part queued behind that thread, before the close is reached, as before; it is now documented.
    • Two threads that close and commit the same file at once are not supported (threadsafety 2).
    • An exit waits for every handed-over close that runs, also for those handed over while it waits, as it waited for the non-daemon thread of the first design.

    The exit results of the first addendum were measured at e5911ed and depended on the order in which the collector finalizes the file and its buffer. At aac7cf8, exit scripts on CPython 3.11, 3.12, 3.13, 3.14, and 3.15 commit a file dropped by a pool thread during threading._shutdown and files collected at module teardown with no part left to upload, and abort the upload of a file collected at teardown with a part left to upload; on 3.11 and 3.15, a file collected by an exit handler registered before PyAthena is committed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions