From 7f97b839764b55f68ad820f98836c8897eaf6648 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Sat, 11 Jul 2026 21:33:37 +0100 Subject: [PATCH 1/3] ext/dom: getElementsByClassName() item() returns wrong element on random access. The loop never advanced past the current match, so a cold item($n) with $n >= 1, or a backwards seek, returned the first match. Fixed by mirroring dom_map_get_elements_item(): advance to the next match once per remaining index. close GH-22701 --- NEWS | 2 + ext/dom/obj_map.c | 16 ++-- ...lementsByClassName_item_random_access.phpt | 84 +++++++++++++++++++ 3 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 ext/dom/tests/modern/common/Element_getElementsByClassName_item_random_access.phpt diff --git a/NEWS b/NEWS index a4f1a24223ec..9cb1c52aacdd 100644 --- a/NEWS +++ b/NEWS @@ -27,6 +27,8 @@ PHP NEWS - DOM: . Fixed bug GH-22570 (Stack overflow when serializing a deeply nested Dom\XMLDocument). (iliaal) + . Fixed getElementsByClassName() item() returning the wrong element on + random access. (David Carlier) - Exif: . Fixed bug GH-11020 (exif_read_data() emits a spurious "Illegal IFD size" diff --git a/ext/dom/obj_map.c b/ext/dom/obj_map.c index 6e65143648d8..800dadbfb422 100644 --- a/ext/dom/obj_map.c +++ b/ext/dom/obj_map.c @@ -346,22 +346,20 @@ static void dom_map_get_by_class_name_item(dom_nnodemap_object *map, zend_long i if (nodep && index >= 0) { dom_node_idx_pair start_point = dom_obj_map_get_start_point(map, nodep, index); if (start_point.node) { - if (start_point.index > 0) { - /* Only start iteration at next point if we actually have an index to seek to. */ - itemnode = php_dom_next_in_tree_order(start_point.node, nodep); - } else { - itemnode = start_point.node; - } + itemnode = start_point.node; } else { itemnode = php_dom_first_child_of_container_node(nodep); + while (itemnode != NULL && !dom_matches_class_name(map, itemnode)) { + itemnode = php_dom_next_in_tree_order(itemnode, nodep); + } } - do { - --start_point.index; + for (; start_point.index > 0 && itemnode != NULL; --start_point.index) { + itemnode = php_dom_next_in_tree_order(itemnode, nodep); while (itemnode != NULL && !dom_matches_class_name(map, itemnode)) { itemnode = php_dom_next_in_tree_order(itemnode, nodep); } - } while (start_point.index > 0 && itemnode); + } } dom_ret_node_to_zobj(map, itemnode, return_value); if (itemnode) { diff --git a/ext/dom/tests/modern/common/Element_getElementsByClassName_item_random_access.phpt b/ext/dom/tests/modern/common/Element_getElementsByClassName_item_random_access.phpt new file mode 100644 index 000000000000..005ad45d3c25 --- /dev/null +++ b/ext/dom/tests/modern/common/Element_getElementsByClassName_item_random_access.phpt @@ -0,0 +1,84 @@ +--TEST-- +Dom\Element::getElementsByClassName() item() random access (cold and backwards) +--EXTENSIONS-- +dom +--FILE-- + + + +
+ +

+ + +HTML); + +$body = $dom->getElementsByTagName('body')->item(0); + +function id(?Dom\Element $e): string { + return $e === null ? 'NULL' : $e->id; +} + +echo "-- cold random access (fresh collection per call) --\n"; +foreach ([0, 1, 2, 3, 4, 5] as $i) { + $collection = $body->getElementsByClassName('x'); + echo "item($i) = ", id($collection->item($i)), "\n"; +} + +echo "-- backwards seek on one collection --\n"; +$collection = $body->getElementsByClassName('x'); +foreach ([4, 2, 0, 3, 1] as $i) { + echo "item($i) = ", id($collection->item($i)), "\n"; +} + +echo "-- item() seed then foreach --\n"; +$collection = $body->getElementsByClassName('x'); +$collection->item(3); +$ids = []; +foreach ($collection as $node) { + $ids[] = $node->id; +} +echo implode(" ", $ids), "\n"; + +echo "-- last-element idiom --\n"; +$collection = $body->getElementsByClassName('x'); +echo "length = ", $collection->length, ", last = ", id($collection->item($collection->length - 1)), "\n"; + +echo "-- live collection after mutation --\n"; +$collection = $body->getElementsByClassName('x'); +echo "item(1) = ", id($collection->item(1)), "\n"; +$dom->getElementById('E1')->remove(); +echo "item(1) = ", id($collection->item(1)), ", length = ", $collection->length, "\n"; + +?> +--EXPECT-- +-- cold random access (fresh collection per call) -- +item(0) = E0 +item(1) = E1 +item(2) = E2 +item(3) = E3 +item(4) = E4 +item(5) = NULL +-- backwards seek on one collection -- +item(4) = E4 +item(2) = E2 +item(0) = E0 +item(3) = E3 +item(1) = E1 +-- item() seed then foreach -- +E0 E1 E2 E3 E4 +-- last-element idiom -- +length = 5, last = E4 +-- live collection after mutation -- +item(1) = E1 +item(1) = E2, length = 4 From 1e9f56cdb4126fe9dc693f9fed2385dda31fa4a8 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 10 Jul 2026 12:48:49 -0400 Subject: [PATCH 2/3] Fix GH-22678: array_multisort() use-after-free on mutating comparator array_multisort() snapshotted each input array's buckets without holding a reference, then ran a comparator that under SORT_STRING invokes Stringable::__toString(); a callback that unset or reassigned the array freed its backing store and elements mid-sort, a use-after-free read in the comparator and a write-back through the freed table during the restructure. Hold a reference on each input HashTable for the duration of the sort and restructure the cached tables, mirroring zend_array_sort_ex() (GH-16648). HT_ALLOW_COW_VIOLATION allows the in-place repack under the held reference. Fixes GH-22678 Closes GH-22680 --- NEWS | 2 ++ ext/standard/array.c | 27 +++++++++++---- ext/standard/tests/array/gh22678.phpt | 48 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 ext/standard/tests/array/gh22678.phpt diff --git a/NEWS b/NEWS index 7324e5c591aa..ee23c0ec7a18 100644 --- a/NEWS +++ b/NEWS @@ -87,6 +87,8 @@ PHP NEWS (Weilin Du) . Fixed integer overflow in getimagesize() and getimagesizefromstring() when parsing an IFF chunk with a size of INT_MAX. (David Carlier, Weilin Du) + . Fixed bug GH-22678 (Use-after-free in array_multisort() when the comparator + mutates the array being sorted). (azchin, iliaal) - Zip: . Fixed bug GH-22649 (ZipArchive::setCommentName() and setCommentIndex() diff --git a/ext/standard/array.c b/ext/standard/array.c index 13731592d836..4527d9a80df8 100644 --- a/ext/standard/array.c +++ b/ext/standard/array.c @@ -6046,6 +6046,7 @@ PHP_FUNCTION(array_multisort) { zval* args; zval** arrays; + HashTable** hashes; Bucket** indirect; uint32_t idx; HashTable* hash; @@ -6167,11 +6168,17 @@ PHP_FUNCTION(array_multisort) for (i = 0; i < array_size; i++) { indirect[i] = indirects + (i * (num_arrays + 1)); } + hashes = safe_emalloc(num_arrays, sizeof(HashTable *), 0); + for (i = 0; i < num_arrays; i++) { + hashes[i] = Z_ARRVAL_P(arrays[i]); + GC_ADDREF(hashes[i]); + HT_ALLOW_COW_VIOLATION(hashes[i]); + } for (i = 0; i < num_arrays; i++) { k = 0; - if (HT_IS_PACKED(Z_ARRVAL_P(arrays[i]))) { - zval *zv = Z_ARRVAL_P(arrays[i])->arPacked; - for (idx = 0; idx < Z_ARRVAL_P(arrays[i])->nNumUsed; idx++, zv++) { + if (HT_IS_PACKED(hashes[i])) { + zval *zv = hashes[i]->arPacked; + for (idx = 0; idx < hashes[i]->nNumUsed; idx++, zv++) { if (Z_TYPE_P(zv) == IS_UNDEF) continue; ZVAL_COPY_VALUE(&indirect[k][i].val, zv); indirect[k][i].h = idx; @@ -6179,8 +6186,8 @@ PHP_FUNCTION(array_multisort) k++; } } else { - Bucket *p = Z_ARRVAL_P(arrays[i])->arData; - for (idx = 0; idx < Z_ARRVAL_P(arrays[i])->nNumUsed; idx++, p++) { + Bucket *p = hashes[i]->arData; + for (idx = 0; idx < hashes[i]->nNumUsed; idx++, p++) { if (Z_TYPE(p->val) == IS_UNDEF) continue; indirect[k][i] = *p; k++; @@ -6200,7 +6207,7 @@ PHP_FUNCTION(array_multisort) /* Restructure the arrays based on sorted indirect - this is mostly taken from zend_hash_sort() function. */ for (i = 0; i < num_arrays; i++) { - hash = Z_ARRVAL_P(arrays[i]); + hash = hashes[i]; hash->nNumUsed = array_size; hash->nNextFreeElement = array_size; hash->nInternalPointer = 0; @@ -6229,6 +6236,14 @@ PHP_FUNCTION(array_multisort) RETVAL_TRUE; clean_up: + for (i = 0; i < num_arrays; i++) { + if (UNEXPECTED(GC_DELREF(hashes[i]) == 0)) { + zend_array_destroy(hashes[i]); + } else { + gc_check_possible_root((zend_refcounted *)hashes[i]); + } + } + efree(hashes); efree(indirects); efree(indirect); efree(func); diff --git a/ext/standard/tests/array/gh22678.phpt b/ext/standard/tests/array/gh22678.phpt new file mode 100644 index 000000000000..c1b71b592520 --- /dev/null +++ b/ext/standard/tests/array/gh22678.phpt @@ -0,0 +1,48 @@ +--TEST-- +GH-22678 (Use-after-free in array_multisort() when the comparator mutates the array) +--FILE-- + 'c', 3 => 'a', 1 => 'b']; +array_multisort($b, SORT_STRING); +echo "repacked: ", implode(',', $b), "\n"; + +class Boom { + public function __toString(): string { + throw new Exception("boom"); + } +} + +$c = [new Boom(), new Boom()]; +try { + array_multisort($c, SORT_STRING); +} catch (Exception $e) { + echo $e::class, ": ", $e->getMessage(), "\n"; +} +?> +--EXPECT-- +freed mid-sort: bool(true) +repacked: a,b,c +Exception: boom From 1feb20156bbfc4450e455a92a026e5a53eba5937 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Mon, 22 Jun 2026 10:27:06 -0400 Subject: [PATCH 3/3] Fix use-after-free on re-entrant ftp_close() during a transfer A user stream wrapper passed to a blocking or non-blocking FTP transfer can re-enter the engine from its stream_read/stream_write handler while the engine still holds the freed ftpbuf_t/databuf_t on the C stack, so the transfer resumes on freed memory. Guard every operation that opens a data connection with an in_use flag: ftp_close() throws while it is set, and the transfer and listing functions refuse to run. The non-blocking checks sit at the userland entry, before the wrapper mutates ftp->direction/stream, so a rejected re-entrant call cannot corrupt the outer transfer; the nb cleanup clears ftp->stream before closing the stream so a stream_close() that calls ftp_close() cannot double-close it. Closes GH-22400 --- ext/ftp/ftp.c | 53 +++++++++++++++++++ ext/ftp/ftp.h | 1 + ext/ftp/php_ftp.c | 30 +++++++++-- ext/ftp/tests/ftp_close_during_transfer.phpt | 44 +++++++++++++++ .../tests/ftp_nb_close_during_transfer.phpt | 44 +++++++++++++++ .../tests/ftp_nb_get_during_nb_transfer.phpt | 44 +++++++++++++++ ext/ftp/tests/ftp_nb_get_during_transfer.phpt | 40 ++++++++++++++ 7 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 ext/ftp/tests/ftp_close_during_transfer.phpt create mode 100644 ext/ftp/tests/ftp_nb_close_during_transfer.phpt create mode 100644 ext/ftp/tests/ftp_nb_get_during_nb_transfer.phpt create mode 100644 ext/ftp/tests/ftp_nb_get_during_transfer.phpt diff --git a/ext/ftp/ftp.c b/ext/ftp/ftp.c index 17a3e10e0d61..1345e378d8e3 100644 --- a/ext/ftp/ftp.c +++ b/ext/ftp/ftp.c @@ -892,6 +892,11 @@ ftp_get(ftpbuf_t *ftp, php_stream *outstream, const char *path, const size_t pat if (ftp == NULL) { return 0; } + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + return 0; + } + ftp->in_use = true; if (!ftp_type(ftp, type)) { goto bail; } @@ -967,9 +972,11 @@ ftp_get(ftpbuf_t *ftp, php_stream *outstream, const char *path, const size_t pat goto bail; } + ftp->in_use = false; return 1; bail: data_close(ftp); + ftp->in_use = false; return 0; } /* }}} */ @@ -1057,6 +1064,11 @@ ftp_put(ftpbuf_t *ftp, const char *path, const size_t path_len, php_stream *inst if (ftp == NULL) { return 0; } + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + return 0; + } + ftp->in_use = true; if (!ftp_type(ftp, type)) { goto bail; } @@ -1097,9 +1109,11 @@ ftp_put(ftpbuf_t *ftp, const char *path, const size_t path_len, php_stream *inst if (!ftp_getresp(ftp) || (ftp->resp != 226 && ftp->resp != 250 && ftp->resp != 200)) { goto bail; } + ftp->in_use = false; return 1; bail: data_close(ftp); + ftp->in_use = false; return 0; } /* }}} */ @@ -1114,6 +1128,11 @@ ftp_append(ftpbuf_t *ftp, const char *path, const size_t path_len, php_stream *i if (ftp == NULL) { return 0; } + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + return 0; + } + ftp->in_use = true; if (!ftp_type(ftp, type)) { goto bail; } @@ -1141,9 +1160,11 @@ ftp_append(ftpbuf_t *ftp, const char *path, const size_t path_len, php_stream *i if (!ftp_getresp(ftp) || (ftp->resp != 226 && ftp->resp != 250 && ftp->resp != 200)) { goto bail; } + ftp->in_use = false; return 1; bail: data_close(ftp); + ftp->in_use = false; return 0; } /* }}} */ @@ -2055,6 +2076,10 @@ ftp_genlist(ftpbuf_t *ftp, const char *cmd, const size_t cmd_len, const char *pa char **entry; char *text; + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + return NULL; + } if ((tmpstream = php_stream_fopen_tmpfile()) == NULL) { php_error_docref(NULL, E_WARNING, "Unable to create temporary file. Check permissions in temporary files directory."); @@ -2156,6 +2181,11 @@ ftp_nb_get(ftpbuf_t *ftp, php_stream *outstream, const char *path, const size_t return PHP_FTP_FAILED; } + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + return PHP_FTP_FAILED; + } + if (ftp->data != NULL) { /* If there is a transfer in action, abort it. * If we don't, we get an invalid state and memory leaks when the new connection gets opened. */ @@ -2223,11 +2253,17 @@ ftp_nb_continue_read(ftpbuf_t *ftp) data = ftp->data; + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + return PHP_FTP_FAILED; + } + /* check if there is already more data */ if (!data_available(ftp, data->fd, false)) { return PHP_FTP_MOREDATA; } + ftp->in_use = true; type = ftp->type; lastch = ftp->lastch; @@ -2251,6 +2287,7 @@ ftp_nb_continue_read(ftpbuf_t *ftp) } ftp->lastch = lastch; + ftp->in_use = false; return PHP_FTP_MOREDATA; } @@ -2265,9 +2302,11 @@ ftp_nb_continue_read(ftpbuf_t *ftp) } ftp->nb = 0; + ftp->in_use = false; return PHP_FTP_FINISHED; bail: ftp->nb = 0; + ftp->in_use = false; data_close(ftp); return PHP_FTP_FAILED; } @@ -2283,6 +2322,10 @@ ftp_nb_put(ftpbuf_t *ftp, const char *path, const size_t path_len, php_stream *i if (ftp == NULL) { return 0; } + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + return PHP_FTP_FAILED; + } if (!ftp_type(ftp, type)) { goto bail; } @@ -2330,16 +2373,24 @@ ftp_nb_put(ftpbuf_t *ftp, const char *path, const size_t path_len, php_stream *i int ftp_nb_continue_write(ftpbuf_t *ftp) { + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + return PHP_FTP_FAILED; + } + /* check if we can write more data */ if (!data_writeable(ftp, ftp->data->fd)) { return PHP_FTP_MOREDATA; } + ftp->in_use = true; + if (ftp_send_stream_to_data_socket(ftp, ftp->data, ftp->stream, ftp->type, true) != SUCCESS) { goto bail; } if (!php_stream_eof(ftp->stream)) { + ftp->in_use = false; return PHP_FTP_MOREDATA; } @@ -2349,10 +2400,12 @@ ftp_nb_continue_write(ftpbuf_t *ftp) goto bail; } ftp->nb = 0; + ftp->in_use = false; return PHP_FTP_FINISHED; bail: data_close(ftp); ftp->nb = 0; + ftp->in_use = false; return PHP_FTP_FAILED; } /* }}} */ diff --git a/ext/ftp/ftp.h b/ext/ftp/ftp.h index 94abc588ca85..91aafad02b79 100644 --- a/ext/ftp/ftp.h +++ b/ext/ftp/ftp.h @@ -73,6 +73,7 @@ typedef struct ftpbuf databuf_t *data; /* Data connection for "nonblocking" transfers */ php_stream *stream; /* output stream for "nonblocking" transfers */ bool nb; /* "nonblocking" transfer in progress */ + bool in_use; /* engine transfer in progress; blocks re-entrant ftp_close */ char lastch; /* last char of previous call */ bool direction; /* recv = 0 / send = 1 */ bool closestream;/* close or not close stream */ diff --git a/ext/ftp/php_ftp.c b/ext/ftp/php_ftp.c index 8fa7675c6e9d..432270cff190 100644 --- a/ext/ftp/php_ftp.c +++ b/ext/ftp/php_ftp.c @@ -648,6 +648,11 @@ PHP_FUNCTION(ftp_nb_fget) } /* configuration */ + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + RETURN_FALSE; + } + ftp->direction = 0; /* recv */ ftp->closestream = 0; /* do not close */ @@ -761,6 +766,10 @@ PHP_FUNCTION(ftp_nb_get) RETURN_THROWS(); } GET_FTPBUF(ftp, z_ftp); + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + RETURN_FALSE; + } XTYPE(xtype, mode); /* ignore autoresume if autoseek is switched off */ @@ -798,8 +807,8 @@ PHP_FUNCTION(ftp_nb_get) ftp->closestream = 1; /* do close */ if ((ret = ftp_nb_get(ftp, outstream, remote, remote_len, xtype, resumepos)) == PHP_FTP_FAILED) { - php_stream_close(outstream); ftp->stream = NULL; + php_stream_close(outstream); VCWD_UNLINK(local); if (*ftp->inbuf) { php_error_docref(NULL, E_WARNING, "%s", ftp->inbuf); @@ -808,8 +817,8 @@ PHP_FUNCTION(ftp_nb_get) } if (ret == PHP_FTP_FINISHED){ - php_stream_close(outstream); ftp->stream = NULL; + php_stream_close(outstream); } RETURN_LONG(ret); @@ -937,6 +946,11 @@ PHP_FUNCTION(ftp_nb_fput) } /* configuration */ + if (ftp->in_use) { + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + RETURN_FALSE; + } + ftp->direction = 1; /* send */ ftp->closestream = 0; /* do not close */ @@ -1077,6 +1091,12 @@ PHP_FUNCTION(ftp_nb_put) } } + if (ftp->in_use) { + php_stream_close(instream); + php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use"); + RETURN_FALSE; + } + /* configuration */ ftp->direction = 1; /* send */ ftp->closestream = 1; /* do close */ @@ -1084,8 +1104,8 @@ PHP_FUNCTION(ftp_nb_put) ret = ftp_nb_put(ftp, remote, remote_len, instream, xtype, startpos); if (ret != PHP_FTP_MOREDATA) { - php_stream_close(instream); ftp->stream = NULL; + php_stream_close(instream); } if (ret == PHP_FTP_FAILED) { @@ -1220,6 +1240,10 @@ PHP_FUNCTION(ftp_close) obj = ftp_object_from_zend_object(Z_OBJ_P(z_ftp)); if (obj->ftp) { + if (obj->ftp->in_use) { + zend_throw_error(NULL, "Cannot close FTP\\Connection while a transfer is in progress"); + RETURN_THROWS(); + } success = ftp_quit(obj->ftp); ftp_close(obj->ftp); obj->ftp = NULL; diff --git a/ext/ftp/tests/ftp_close_during_transfer.phpt b/ext/ftp/tests/ftp_close_during_transfer.phpt new file mode 100644 index 000000000000..72e410603921 --- /dev/null +++ b/ext/ftp/tests/ftp_close_during_transfer.phpt @@ -0,0 +1,44 @@ +--TEST-- +ftp_close() from a stream wrapper during a transfer throws instead of freeing the connection +--EXTENSIONS-- +ftp +pcntl +--FILE-- +getMessage(), "\n"; +} + +ftp_close($ftp); +echo "closed\n"; +?> +--EXPECT-- +bool(true) +Cannot close FTP\Connection while a transfer is in progress +closed diff --git a/ext/ftp/tests/ftp_nb_close_during_transfer.phpt b/ext/ftp/tests/ftp_nb_close_during_transfer.phpt new file mode 100644 index 000000000000..702648f69592 --- /dev/null +++ b/ext/ftp/tests/ftp_nb_close_during_transfer.phpt @@ -0,0 +1,44 @@ +--TEST-- +ftp_close() from a stream wrapper during a non-blocking transfer throws instead of freeing the connection +--EXTENSIONS-- +ftp +pcntl +--FILE-- +getMessage(), "\n"; +} + +ftp_close($ftp); +echo "closed\n"; +?> +--EXPECT-- +bool(true) +Cannot close FTP\Connection while a transfer is in progress +closed diff --git a/ext/ftp/tests/ftp_nb_get_during_nb_transfer.phpt b/ext/ftp/tests/ftp_nb_get_during_nb_transfer.phpt new file mode 100644 index 000000000000..deb1698c77c5 --- /dev/null +++ b/ext/ftp/tests/ftp_nb_get_during_nb_transfer.phpt @@ -0,0 +1,44 @@ +--TEST-- +Re-entrant ftp_nb_get() from a stream wrapper during an active non-blocking ftp_nb_get() must not corrupt the outer transfer +--EXTENSIONS-- +ftp +pcntl +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +closed diff --git a/ext/ftp/tests/ftp_nb_get_during_transfer.phpt b/ext/ftp/tests/ftp_nb_get_during_transfer.phpt new file mode 100644 index 000000000000..c7920496feb4 --- /dev/null +++ b/ext/ftp/tests/ftp_nb_get_during_transfer.phpt @@ -0,0 +1,40 @@ +--TEST-- +Re-entrant ftp_nb_get() from a stream wrapper during a blocking ftp_get() must not free the active data connection +--EXTENSIONS-- +ftp +pcntl +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +closed