719-286-0751 [email protected]

Fixing the “TypeError: stream_get_meta_data(): Argument #1 ($stream) must be of type resource, bool given” Error in Magento 2

One of the more disruptive Magento 2 failures on S3 remote storage is a PHP fatal that nobody threw on purpose: TypeError: stream_get_meta_data(): Argument #1 ($stream) must be of type resource, bool given. It surfaces in your logs as PHP Fatal error: Uncaught TypeError ... in .../module-aws-s3/Driver/AwsS3.php, and it does not fail gracefully. A web request dies mid-response, or worse, a long-running queue consumer crashes and your async operations stop draining. This one is gated on two things — running Magento with the AwsS3 remote storage driver, and a teardown path (most often bulk CSV export, amplified by async consumers) that leaves a non-resource value in the driver's stream registry. We'll cover what actually triggers the bool given message, which half of the fix you already have if you're on a recent version, and a two-part patch for the parts core still hasn't fixed.

Need help fixing your Magento 2 store?
If this error is impacting live revenue, our senior Magento team can help you debug and deploy a fix safely.
Contact us →

Why Magento 2 throws this error

The crash lives in Magento\AwsS3\Driver\AwsS3, specifically fileClose() and __destruct(). fileClose() calls stream_get_meta_data($resource)['uri'] on whatever it pulls from the in-memory $this->streams registry. That registry is populated in fileOpen() by $this->streams[$path] = tmpfile(); — and tmpfile() returns bool false when it can't get a file descriptor or temp space. When a literal false lands in that slot and later reaches stream_get_meta_data(), PHP 8 throws the exact TypeError this article is named after: Argument #1 ($stream) must be of type resource, bool given. The throw happens during object teardown, and a throwable escaping a destructor becomes an unrecoverable fatal.

It is worth being precise about the value, because the two failure modes produce two different messages:

  • A literal bool false in the registry (a failed tmpfile(), or fileClose(false)) is what produces the titled must be of type resource, bool given error. This is the one that kills the process.
  • A stream that was fclose()'d elsewhere does not become false — it becomes type resource (closed). Passing that to stream_get_meta_data() throws a different message, supplied resource is not a valid stream resource. Same \TypeError class, different text. If your log shows that string, you're on the closed-handle variant, not the bool one.
  • PHP 8 promoted both type mismatches: where PHP 7 warned and returned false, PHP 8 throws a \TypeError.
  • \TypeError extends \Error, not \Exception. Both implement \Throwable, but a catch (\Exception $e) block silently misses every \Error subclass — which is why AwsS3::__destruct()'s catch (\Exception $e) lets the TypeError escape and escalate to a fatal.

1. Confirm your version and locate the core driver

Start with the artifact, not assumptions. Half of the fix below already shipped in core, so the first thing to establish is which Magento version you're on.

php -v && composer show magento/product-community-edition 2>/dev/null | grep -i versions

You should see PHP 8.x and your Magento version. This matters: the top-of-method is_resource() guard in fileClose() (Step 4) shipped in stock Magento 2.4.6 and is present through 2.4.8. If you're on 2.4.6 or later you already have it, so Step 4 will be a no-op for you. The destructor catch (\Throwable) widening and the inner-loop stale-stream cleanup (Steps 3 and 4's loop) are still absent even in 2.4.8, so those apply regardless of version. Confirm the affected class is present:

ls -1 vendor/magento/module-aws-s3/Driver/AwsS3.php

If the path prints and you're on a version below 2.4.6, both halves of the fix are missing in stock and the bug can bite. On 2.4.6+, treat this as the destructor-widening fix only.

2. Reproduce the fatal from the logs

Pull the actual failure so you can prove it's the destructor path and not a network error talking to your bucket. Use grep -nE so the alternation works and you get line numbers.

grep -rnE "stream_get_meta_data|Uncaught TypeError" var/log var/report 2>/dev/null | head

You should see an uncaught \TypeError from stream_get_meta_data() inside module-aws-s3/Driver/AwsS3.php during destruction. Read the message text carefully: ... bool given is the failed-tmpfile() path; supplied resource is not a valid stream resource is the closed-handle path. Either way the fix below covers it. If you instead see S3 auth or timeout errors, you're chasing a different problem.

Need help fixing your Magento 2 store?
If this error is impacting live revenue, our senior Magento team can help you debug and deploy a fix safely.
Contact us →

3. Widen the destructor catch to \Throwable

This is the part stock core still gets wrong, in every version through 2.4.8. The destructor only catches \Exception, so the \TypeError walks straight past it and escalates to a fatal. Catch the common ancestor instead.

// Magento\AwsS3\Driver\AwsS3::__destruct()
} catch (\Throwable $e) {
    // a throw escaping a destructor becomes a fatal error
    $this->logger->critical($e);
}

Now any failure during teardown — TypeError included — is logged as critical rather than escalated to a fatal that takes down the request or the consumer worker. A destructor should never let a throwable escape, and catching \Throwable enforces that.

4. Guard fileClose() against non-resources

Catching the throwable stops the crash, but you still want the registry to never hand a non-resource to stream_get_meta_data(). There are two distinct guards here. The top-of-method guard is what core added in 2.4.6 — if you're on 2.4.6+ you already have this line and can skip it. The inner-loop guard that evicts stale slots is still missing even in 2.4.8, so add it regardless.

// Magento\AwsS3\Driver\AwsS3::fileClose()
// Top-of-method guard: shipped in core 2.4.6+. Add only if on < 2.4.6.
if (!is_resource($resource)) {
    return false;
}
$resourcePath = stream_get_meta_data($resource)['uri'];

// Inner-loop guard: still absent in stock 2.4.8 — add this.
foreach ($this->streams as $path => $stream) {
    if (!is_resource($stream)) {
        unset($this->streams[$path]);
        continue;
    }
    // ... existing match-and-write logic
}

The top guard returns false (matching the method's bool return type) the moment a bool false or a closed handle arrives, so stream_get_meta_data() only ever sees a real resource — this is what stops the teardown TypeError on the incoming handle from __destruct()'s loop. The inner guard skips and evicts registry slots that no longer hold an open resource, so the iteration never reads metadata off a dead slot. Neither prevents a literal double-fclose() of an open stream — in stock code each matched slot is unset before its fclose() and __destruct() iterates a value copy, so an open handle is never closed twice. The real failure is reading from a slot that holds a non-resource, and that is exactly what these guards prevent.

5. Ship it as a composer patch and verify

The change is in vendor/, so deliver it through cweagans/composer-patches and confirm it applies. The patch output mentions aws-s3 and patch, so grep for either with a literal pattern.

composer install 2>&1 | grep -iE 'aws-s3|patch'

You should see the patch report as applied with no "Could not apply patch" errors. If you scoped the patch to the lines that are actually missing in your version, it will apply cleanly across every environment and survive each composer install.

6. Restart consumers and confirm no fatal

Bring the workers back and watch a real batch run.

bin/magento queue:consumers:list && bin/magento queue:consumers:start async.operations.all --max-messages=200

The consumer should process its batch and exit cleanly, with no "Uncaught TypeError" in the logs. We've seen this most often when remote storage is paired with high-throughput async consumers — not because valid handles get torn down repeatedly, but because a worker that opens thousands of streams is far more likely to exhaust file descriptors or /tmp space. When tmpfile() fails under that pressure it returns bool false, that false lands in the registry, and it's what blows up at teardown.

When this points to a deeper problem

A single patch closes this hole, but the pattern behind it is worth auditing:

  • The catch (\Exception) idiom is everywhere, and across a PHP 8 codebase it silently lets every \Error subclass through — a latent bug in any cleanup, shutdown, or finally-style code. This is genuinely the part core itself still hasn't fixed in AwsS3::__destruct().
  • Resource registries like $this->streams track handles without an invariant that each entry is still a live resource, so a failed tmpfile() or an externally closed stream leaves a non-resource in place for teardown to trip over.
  • Destructors that perform real I/O do failure-prone work at the worst possible moment for a throw.
  • Long-running consumers magnify the blast radius two ways: one bad teardown takes down the whole worker rather than one request, and sustained stream volume makes the FD/temp exhaustion that produces bool false far more likely.

Grep for catch (\Exception and bare catch (Exception inside __destruct() and shutdown handlers, then move each to \Throwable:

grep -rnE 'catch \(\\?Exception' --include='*.php' app/code vendor/magento 2>/dev/null

Audit every stream_get_meta_data(), fclose(), fread(), and fwrite() for a missing is_resource() guard, and check whether the trigger in your store is a specific path — bulk CSV export to remote storage is the classic one — rather than arbitrary traffic. Patching one driver clears the symptom; enforcing "destructors catch \Throwable and never throw" and "the stream registry only ever holds live resources" is the durable fix.

You now have everything to stop the crash and keep your consumers draining. Check your version first, widen the destructor catch (still needed on every release through 2.4.8), add the resource guards your version is missing, ship them as a scoped composer patch, and the fatal that used to kill requests and workers becomes a logged line you can ignore.

Install our webapp on your iPhone! Tap and then Add to homescreen.
Share This