Skip to content

fix: sam local invoke leaks Docker container and temp directory on OOM - #9184

Open
Adityaj0 wants to merge 5 commits into
aws:developfrom
Adityaj0:fix/local-invoke-oom-cleanup-leak
Open

fix: sam local invoke leaks Docker container and temp directory on OOM#9184
Adityaj0 wants to merge 5 commits into
aws:developfrom
Adityaj0:fix/local-invoke-oom-cleanup-leak

Conversation

@Adityaj0

Copy link
Copy Markdown

Which issue(s) does this change fix?

Fixes #9182

Why is this change necessary?

LambdaRuntime._on_invoke_done() calls _check_exit_state(container) before self._container_manager.stop(container) and self._clean_decompressed_paths():

def _on_invoke_done(self, container):
    if container:
        self._check_exit_state(container)
        self._container_manager.stop(container)
    self._clean_decompressed_paths()

_on_invoke_done is invoked unconditionally from invoke()'s finally block, but has no try/except of its own. When a function is OOM-killed, _check_exit_state() raises ContainerFailureError, and that exception propagates immediately out of _on_invoke_done, skipping both _container_manager.stop(container) and _clean_decompressed_paths().

Every OOM'd invocation of sam local invoke (or start-api/start-lambda) therefore leaves a stopped-but-not-removed Docker container and an unzipped-code temp directory on disk. This accumulates over repeated local-invoke testing, which is exactly the workflow most likely to trigger it (iterating on MemorySize tuning).

How does this change work?

Wrap the exit-state check in a try/finally so the container is always stopped and the temp directory is always cleaned up, regardless of whether _check_exit_state raises:

def _on_invoke_done(self, container):
    try:
        if container:
            self._check_exit_state(container)
    finally:
        if container:
            self._container_manager.stop(container)
        self._clean_decompressed_paths()

The ContainerFailureError (or any other exception from _check_exit_state) still propagates after cleanup runs — this only fixes cleanup, not the error-reporting behavior for OOM.

What tests ran and what were the results?

Added a regression test, test_on_invoke_done_stops_container_and_cleans_paths_even_when_check_exit_state_raises, in tests/unit/local/lambdafn/test_runtime.py. It fails against the pre-fix code (stop() is never called when _check_exit_state raises) and passes after the fix.

Full unit suite (tests/unit) passes: 9387 passed, 25 skipped.
mypy on the changed file: clean.

Checklist

  • Add/update tests for this change
  • make pr passes locally (unit tests + mypy on changed files; full local make pr target run via pytest tests/unit -q and scoped mypy)
  • Write a clear PR title and description

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

_on_invoke_done() called _check_exit_state(container) before
_container_manager.stop(container) and _clean_decompressed_paths(). When a
function is OOM-killed, _check_exit_state() raises ContainerFailureError,
which propagated out of _on_invoke_done() before either cleanup step ran,
leaking the stopped-but-not-removed container and the per-invocation
decompressed-code temp directory on every OOM'd invocation.

Wrap the check in try/finally so cleanup always runs regardless of whether
_check_exit_state() raises.

Fixes aws#9182
@Adityaj0
Adityaj0 requested a review from a team as a code owner August 15, 2026 00:34
@github-actions github-actions Bot added area/local/start-api sam local start-api command area/local/invoke sam local invoke command area/local/start-invoke pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at. labels Aug 15, 2026

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: e1f4bf6..5950ac4
Files: 2
Comments: 1

Comment thread samcli/local/lambdafn/runtime.py Outdated
self._check_exit_state(container)
finally:
if container:
self._container_manager.stop(container)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[RESOURCE_MANAGEMENT] The two cleanup steps run sequentially in the same finally block, so a failure in the first still skips the second — the same class of leak this PR fixes.

ContainerManager.stop() calls Container.stop() and Container.delete(), and both re-raise docker.errors.APIError unless the message matches the "removal of container ... is already in progress" special case (see samcli/local/docker/container.py). If that raises, self._clean_decompressed_paths() never runs, leaving the unzipped-code temp directory behind. It also replaces the in-flight ContainerFailureError, so an OOM'd invocation would surface an opaque Docker API error instead of "Container invocation failed due to maximum memory usage".

Nesting the cleanup makes both steps independent and preserves the original exception:

try:
   if container:
       self._check_exit_state(container)
finally:
   try:
       if container:
           self._container_manager.stop(container)
   finally:
       self._clean_decompressed_paths()

Reviewer noted that container_manager.stop() and clean_decompressed_paths()
were both in the same finally block, so a docker.errors.APIError from
Container.stop()/delete() (raised for any Docker API error other than the
"removal already in progress" special case) would still skip
_clean_decompressed_paths(), reproducing the same leak class this PR
fixes for the OOM path. Nest the two cleanup steps in their own
try/finally so a failure in one cannot suppress the other, and the
original exception (e.g. ContainerFailureError) keeps propagating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Adityaj0

Copy link
Copy Markdown
Author

Verified this independently and it's correct.

Traced ContainerManager.stop() (samcli/local/docker/manager.py:111) → Container.stop()/Container.delete() (samcli/local/docker/container.py:363,392): both catch docker.errors.NotFound (fine) but re-raise docker.errors.APIError for anything other than the "removal of container ... is already in progress" message. So with the single flat finally block from the previous commit, a stop-time API error (unrelated to OOM — e.g. a transient daemon hiccup while removing the container) would still propagate out of _on_invoke_done before _clean_decompressed_paths() runs, leaving the temp dir behind — the exact leak class this PR is fixing, just moved one step later. It would also mask the original ContainerFailureError from the OOM check, since a new exception raised while another is propagating replaces it as what surfaces to the caller.

Pushed 3f23ceb: nested the stop() call and _clean_decompressed_paths() into their own try/finally, matching what was suggested, so a failure in either cleanup step can't suppress the other, and the original exception from _check_exit_state still propagates normally when both cleanup steps succeed.

Added two regression tests in tests/unit/local/lambdafn/test_runtime.py:

  • test_on_invoke_done_cleans_paths_even_when_container_manager_stop_raisesstop() raises, _clean_decompressed_paths must still be called
  • test_on_invoke_done_cleans_paths_when_both_check_exit_state_and_stop_raise — OOM (ContainerFailureError) and a subsequent stop failure, cleanup still runs

Verified: tests/unit/local/lambdafn/ full suite passes (134 passed), including the existing regression test from this PR and both new ones. black --check clean on both changed files.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: e1f4bf6..3f23ceb
Files: 2
Comments: 1

finally:
try:
if container:
self._container_manager.stop(container)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] Cleanup now runs in finally blocks, so an exception from cleanup replaces the in-flight exception instead of propagating it. Concretely, on an OOM'd invoke where _check_exit_state() has raised ContainerFailureError, if self._container_manager.stop(container) or self._clean_decompressed_paths() then raises, the OOM error is discarded (kept only as __context__) and the cleanup error surfaces instead.

This is reachable with the existing code:

  • ContainerManager.stop() calls Container.stop() and Container.delete(), both of which re-raise docker.errors.APIError unless the message matches the "removal of container ... is already in progress" special case (samcli/local/docker/container.py). Container.delete() additionally does shutil.rmtree(self._host_tmp_dir).
  • _clean_decompressed_paths() calls shutil.rmtree() with no ignore_errors.

The user-visible impact is a regression in error reporting: ContainerFailureError is a UserException, so it produces the friendly "Container invocation failed due to maximum memory usage" message and exit code 1. A raw docker.errors.APIError / OSError is not, so the actual OOM cause is hidden behind an unhandled-exception trace. The PR's own test_on_invoke_done_cleans_paths_when_both_check_exit_state_and_stop_raise encodes this behavior by asserting RuntimeError propagates rather than ContainerFailureError.

Since these are best-effort cleanup steps whose failures should not determine the invoke result, log them instead of letting them escape:

def oninvoke_done(self, container):
   try:
       if container:
           self._check_exit_state(container)
   finally:
       if container:
           try:
               self._container_manager.stop(container)
           except Exception:  # best-effort cleanup
               LOG.warning("Failed to stop/remove container during cleanup", exc_info=True)
       try:
           self._clean_decompressed_paths()
       except Exception:  # best-effort cleanup
           LOG.warning("Failed to clean decompressed code directories", exc_info=True)

This still guarantees both cleanup steps run (the point of the fix) while preserving the original error. If you prefer cleanup failures to remain fatal when there is no in-flight exception, the alternative is to re-raise the original exception explicitly when one exists; either way, the OOM error should not be swallowed. The third test's assertion would need updating to match whichever behavior you choose.

…rror

The previous nested try/finally made both cleanup steps independent of
each other, but a failure in either one still propagated and replaced
whatever exception was already in flight from _check_exit_state() --
e.g. an OOM'd invoke's ContainerFailureError would be discarded in favor
of a raw docker.errors.APIError from a failed stop(), turning a friendly
"Container invocation failed due to maximum memory usage" message into
an opaque unhandled-exception trace.

Cleanup failures are best-effort and shouldn't determine the invoke
result: log and swallow them instead, so the original error (if any)
is what actually propagates, while both cleanup steps still always run.
@Adityaj0

Copy link
Copy Markdown
Author

Good catch — you're right that nesting the try/finally only fixed the "both steps always run" half of this; it didn't stop a cleanup failure from replacing whatever exception was already in flight.

Pushed a fix (67676eb) that makes both cleanup steps best-effort: stop() and _clean_decompressed_paths() are now each wrapped in their own try/except, logged via LOG.warning(..., exc_info=True) on failure instead of raising. This means:

  • Both cleanup steps still always run, independently of each other (unchanged from the previous fix).
  • A failure in either cleanup step no longer replaces an in-flight exception from _check_exit_state() — an OOM'd invoke now reliably surfaces ContainerFailureError ("Container invocation failed due to maximum memory usage") even if stop() or the temp-dir cleanup also fails.
  • When there's no in-flight exception (the normal case), a cleanup failure is now swallowed with a log warning rather than surfacing a bare docker.errors.APIError/OSError to the user, since these are best-effort steps that shouldn't determine the invoke's overall success/failure.

Updated the existing test_on_invoke_done_cleans_paths_even_when_container_manager_stop_raises and test_on_invoke_done_cleans_paths_when_both_check_exit_state_and_stop_raise to match (the latter now asserts ContainerFailureError propagates, not the cleanup-only RuntimeError), and added two new tests covering _clean_decompressed_paths() itself raising, both alone and alongside the original ContainerFailureError. Full tests/unit/local/lambdafn/ suite (136 tests) passes, ruff format clean.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: e1f4bf6..67676eb
Files: 2
Comments: 2

except Exception:
LOG.warning("Failed to stop/remove container during cleanup", exc_info=True)
try:
self._clean_decompressed_paths()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[RESOURCE_MANAGEMENT] Swallowing the exception from _clean_decompressed_paths() turns a one-off temp-dir removal failure into a permanent, silent leak, because that method is not restartable.

# samcli/local/lambdafn/runtime.py:480
def cleandecompressed_paths(self):
   LOG.debug("Cleaning all decompressed code dirs")
   with self._lock:
       for decompressed_dir in self._temp_uncompressed_paths_to_be_cleaned:
           shutil.rmtree(decompressed_dir)
       self._temp_uncompressed_paths_to_be_cleaned = []

If shutil.rmtree raises on any entry (Windows file locks on a directory that was bind-mounted into the container, or an OSError from a partially-removed tree), the loop aborts and self._temp_uncompressed_paths_to_be_cleaned = [] never runs. The failing path stays in the list forever, and every later invoke on the same LambdaRuntime instance — start-api/start-lambda reuse one instance for the life of the server — re-enters the loop, hits that same stale entry first (now typically FileNotFoundError if it was in fact deleted), and aborts again. Newer decompressed dirs appended after it are then never cleaned. Before this PR the error at least surfaced to the user; now it is a warning line that leaves a growing set of temp dirs behind.

Making the loop itself per-path resilient fixes the root cause and makes the outer except here redundant:

def cleandecompressed_paths(self):
   LOG.debug("Cleaning all decompressed code dirs")
   with self._lock:
       paths_to_clean = self._temp_uncompressed_paths_to_be_cleaned
       self._temp_uncompressed_paths_to_be_cleaned = []
   for decompressed_dir in paths_to_clean:
       try:
           shutil.rmtree(decompressed_dir)
       except OSError:
           LOG.warning("Failed to remove temporary directory %s", decompressed_dir, exc_info=True)

# Docker/OS error, and each step must run independently of the other's success.
if container:
try:
self._container_manager.stop(container)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[RESOURCE_MANAGEMENT] self._container_manager.stop(container) can itself skip container removal, so catching and logging its failure here does not guarantee the container is gone — the leak this PR is fixing can still occur:

# samcli/local/docker/manager.py:111
def stop(self, container: Container) -> None:
   if self.do_shutdown_event:
       container.stop()
   container.delete()

Container.stop() (container.py:363) re-raises docker.errors.APIError for anything other than the "removal of container ... is already in progress" message, and that propagates out of ContainerManager.stop() before container.delete() — the call that actually removes the container — ever runs. The new except Exception then logs a warning and moves on, so the stopped-but-not-removed container survives exactly as it did before this fix. This path is reached whenever --shutdown is used (do_shutdown_event is set from that flag in invoke_context.py:888), which is the same iterative local-testing workflow the PR targets.

Guaranteeing removal requires delete() to run regardless of stop()'s outcome:

def stop(self, container: Container) -> None:
   try:
       if self.do_shutdown_event:
           container.stop()
   finally:
       container.delete()

…ging them

Two deeper issues in the cleanup path the previous commit only papered
over by logging and swallowing failures:

1. ContainerManager.stop() called container.stop() then container.delete()
   sequentially. If stop() raised (e.g. docker.errors.APIError), delete()
   -- the call that actually removes the container -- never ran, so
   catching that exception at the call site just logged a warning while
   the container stayed orphaned. Wrap stop() in try/finally so delete()
   always runs regardless of stop()'s outcome.

2. LambdaRuntime._clean_decompressed_paths() aborted its loop on the
   first shutil.rmtree() failure, and only cleared
   self._temp_uncompressed_paths_to_be_cleaned after the loop finished.
   Since that list is append-only, a single failing entry got stuck in
   it forever: every later invoke would re-hit the same failing path
   first and abort again, permanently blocking cleanup of every temp
   dir added afterwards for the life of the LambdaRuntime instance
   (start-api/start-lambda reuse one instance for the server's lifetime).
   Snapshot-and-clear the list under the lock up front, then clean each
   path independently so one failure can't block the rest or get stuck.
@Adityaj0

Copy link
Copy Markdown
Author

Both confirmed real, and you're right that catching-and-logging at the call site wasn't enough — the leaks were still happening one level down, just silently now instead of loudly. Fixed both at the source (41f7ea8):

ContainerManager.stop() — wrapped container.stop() in try/finally so container.delete() (the call that actually removes the container) always runs, even if stop() raises. Added test_must_call_delete_even_when_container_stop_raises, confirmed it fails pre-fix (delete never called) and passes post-fix.

_clean_decompressed_paths() — you're right this is worse than a one-off leak: since self._temp_uncompressed_paths_to_be_cleaned is append-only and was only cleared after the loop finished, a single failing rmtree() call left every entry stuck in the list permanently, and every subsequent invoke on the same LambdaRuntime (reused for the life of start-api/start-lambda) would re-hit that same first bad entry and abort again — silently blocking cleanup of every dir added afterward. Snapshot-and-clear the list under the lock up front, then clean each path independently with its own try/except so one failure can't block the rest or get stuck. Went with your suggested shape essentially as-is. Added two tests: one confirming all paths are cleaned and the list is cleared on the happy path, and one with a failing entry in the middle of the list confirming the other two still get cleaned and the list still ends up empty (not stuck) despite the failure.

Full tests/unit/local (1303 tests) passes, ruff format clean.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: e1f4bf6..41f7ea8
Files: 4
Comments: 2

with self._lock:
for decompressed_dir in self._temp_uncompressed_paths_to_be_cleaned:
shutil.rmtree(decompressed_dir)
paths_to_clean = self._temp_uncompressed_paths_to_be_cleaned

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[CONCURRENCY] The snapshot taken under the lock does not actually isolate paths_to_clean from concurrent producers, because _get_code_dir() mutates the same list object without holding self._lock:

# samcli/local/lambdafn/runtime.py (_get_code_dir, unchanged by this PR)
self._temp_uncompressed_paths_to_be_cleaned += [decompressed_dir]

+= on a list is a read / in-place-extend / store-back sequence. If it interleaves with the new swap, the following happens:

  1. Producer thread loads the attribute → old list L1.
  2. Cleanup thread takes the lock, sets paths_to_clean = L1, rebinds the attribute to [], releases the lock.
  3. Producer extends L1 with its freshly unzipped dir and stores the attribute back to L1.
  4. Cleanup thread, now iterating outside the lock, rmtrees that dir while the producer's invoke is still about to mount it — and the attribute is now back to L1, holding paths that were already removed.

This is reachable in practice: sam local start-api / start-lambda run Flask with threaded=True when not debugging (samcli/local/services/base_local_service.py:79), and in non-warm mode every request thread goes through _get_code_dir() and then _on_invoke_done()_clean_decompressed_paths() on the same LambdaRuntime instance. Moving rmtree out of the lock widens the window and the comment above the swap asserts a safety property the code does not have.

Guarding the producer side makes the snapshot meaningful:

# in getcode_dir()
with self._lock:
   self._temp_uncompressed_paths_to_be_cleaned.append(decompressed_dir)

for decompressed_dir in paths_to_clean:
try:
shutil.rmtree(decompressed_dir)
except OSError:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[RESOURCE_MANAGEMENT] A path whose rmtree fails is now dropped from _temp_uncompressed_paths_to_be_cleaned permanently (the list is cleared up front), so that directory is never retried by a later invoke or by WarmLambdaRuntime.clean_running_containers_and_related_resources() — it leaks silently for the rest of the process, with only a warning log.

This matters most for the transient failures that dominate this call site: _clean_decompressed_paths() runs immediately after the container is stopped/removed, and removing a directory that was just bind-mounted into a container commonly fails once with PermissionError on Windows and succeeds moments later. For a long-running sam local start-api in non-warm mode, every such one-off failure accumulates a permanently orphaned temp dir — the same accumulating-leak class of bug this PR set out to fix.

Re-queueing only the failed paths keeps the "one bad entry can't block the others" property from this change while preserving a retry:

failed_paths = []
for decompressed_dir in paths_to_clean:
   try:
       shutil.rmtree(decompressed_dir)
   except OSError:
       LOG.warning("Failed to remove temporary directory %s", decompressed_dir, exc_info=True)
       failed_paths.append(decompressed_dir)

if failed_paths:
   with self._lock:
       self._temp_uncompressed_paths_to_be_cleaned += failed_paths

Note on prior review threads: the two cleanup steps now running independently, the in-flight exception no longer being replaced by a cleanup error, and ContainerManager.stop() skipping container.delete() after a failed container.stop() all appear addressed by this revision. The decision to log rather than re-raise cleanup failures in _on_invoke_done() was explicitly stated as intentional, so it is not re-raised here.

…ses; retry failed removals

Two further gaps in the cleanup path:

1. _get_code_dir() appended to self._temp_uncompressed_paths_to_be_cleaned
   with `+=` outside self._lock, while _clean_decompressed_paths() snapshots
   and clears that same list under the lock. start-api/start-lambda run
   threaded, so a request thread's append could race with a concurrent
   cleanup's snapshot: the new entry could be silently dropped, or worse,
   removed by rmtree while the request that just unzipped it is still about
   to use it. Hold the lock on the producer side too.

2. A path whose rmtree() failed was being dropped from the cleanup list
   permanently, leaking silently for the rest of the process. Since
   failures at this call site (a directory just unmounted from a
   just-stopped container) are commonly transient, requeue failed paths
   instead of discarding them, so they get retried on the next cleanup
   pass.
@Adityaj0

Copy link
Copy Markdown
Author

Both real, and both go deeper than the previous fixes reached:

Race between producer and consumer — confirmed: _get_code_dir() (unchanged by this PR, line 465) does self._temp_uncompressed_paths_to_be_cleaned += [decompressed_dir] with no lock, while _clean_decompressed_paths() snapshots-and-clears that same list under self._lock. Since += on a list attribute is a load/extend/store-back sequence, not an atomic in-place mutation, it can interleave with the lock-protected swap exactly as you described. Fixed by wrapping the append in with self._lock: too, so the snapshot is actually meaningful.

Failed removals dropped permanently — also confirmed as a regression from the previous fix: the snapshot-and-clear approach fixed "one bad entry blocks/stalls everything" but introduced "one bad entry silently vanishes forever," trading one leak class for another, quieter one. Requeued failed paths back into the list (under lock) instead of discarding them, so a transient failure gets retried on the next cleanup pass rather than leaking for the life of the process.

Pushed c298d1b. Added test_appending_to_cleanup_list_holds_the_same_lock_cleanup_uses (asserts _get_code_dir enters/exits self._lock and the entry lands in the list), and test_failed_path_is_requeued_for_retry_not_dropped_permanently (asserts a failed path stays queued and is actually retried — successfully — on the next call). Updated the earlier "list is cleared" assertion, which is no longer correct now that failures are requeued rather than dropped.

Full tests/unit/local (1305 tests) passes, ruff format clean.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: e1f4bf6..c298d1b
Files: 4
Comments: 2

# `start-api`/`start-lambda` run threaded, so a request thread appending here can
# race with a concurrent cleanup's snapshot-and-clear, either losing this entry
# entirely or (worse) having it removed by rmtree while still in use.
with self._lock:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[CONCURRENCY] Taking self._lock around the append does fix the torn-list/lost-entry race, but it does not prevent the second scenario the new comment claims it does — "having it removed by rmtree while still in use". That race is caused by the list being process-global rather than per-invocation, and it survives this change:

# thread A (request 1): create() -> getcode_dir() appends dirA, container A starts with dirA bind-mounted
# thread B (request 2): invoke finishes -> oninvoke_done() -> cleandecompressed_paths()
# snapshot = [dirA, dirB]  -> rmtree(dirA) while container A is still running

_clean_decompressed_paths() always removes every queued path, and _on_invoke_done() calls it on each individual invoke, so any concurrent in-flight invocation's decompressed directory is deleted underneath it. samcli/local/services/base_local_service.py:62 runs Flask multi-threaded whenever not debugging, and start-api/start-lambda use plain LambdaRuntime (not WarmLambdaRuntime) unless --warm-containers is passed, so two concurrent requests against .jar/.zip CodeUris hit this. The result is a container reading from a deleted host directory — a harder-to-diagnose failure than the leak being fixed.

Fixing it properly means scoping the paths to the invocation (e.g. have create()/_get_code_dir() return the paths it unzipped and pass only those to _on_invoke_done() for cleanup). If that is out of scope for this PR, please drop the "removed by rmtree while still in use" claim from the comment so it doesn't read as a guarantee the code does not provide.

# was just bind-mounted into a just-stopped container isn't released yet), so retry
# them on the next cleanup pass instead of dropping them permanently.
with self._lock:
self._temp_uncompressed_paths_to_be_cleaned += failed_paths

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[RESOURCE_MANAGEMENT] Requeuing failed paths only helps a process that will call _clean_decompressed_paths() again. For sam local invoke — the headline case in the PR description — there is no later pass, so a requeued path is still a permanent leak with only a warning:

  • InvokeContext.exit (samcli/commands/local/cli_common/invoke_context.py:348) calls _clean_running_containers_and_related_resources() only when _containers_mode == ContainersMode.WARM.
  • For cold mode it calls LambdaRuntime.clean_runtime_containers(), which only handles the durable-lambda and emulator containers and never touches _temp_uncompressed_paths_to_be_cleaned.
  • Even in warm mode, anything requeued by the final clean_running_containers_and_related_resources() call is dropped when the process exits.

Since the added comment states these rmtree failures are "commonly transient (e.g. a directory that was just bind-mounted into a just-stopped container isn't released yet)", the single-shot invoke path — where the container was stopped microseconds earlier — is exactly where a transient failure is most likely and where the retry never happens.

A last best-effort attempt at teardown closes the gap, e.g. calling self._clean_decompressed_paths() from LambdaRuntime.clean_runtime_containers(), which InvokeContext.exit already invokes for every container mode:

def clean_runtime_containers(self):
   ...
   # existing container cleanup
   ...
   # Final best-effort pass so paths requeued by earlier failures get one more attempt
   # before the process exits.
   self._clean_decompressed_paths()

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

Labels

area/local/invoke sam local invoke command area/local/start-api sam local start-api command area/local/start-invoke pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sam local invoke leaks Docker container and temp directory when function is OOM-killed

1 participant