From 7d7cc4a92f2e57205bbbf565c72359fc70989605 Mon Sep 17 00:00:00 2001 From: Greg Sabino Mullane Date: Tue, 15 Sep 2026 13:13:53 -0400 Subject: [PATCH] Allow specific information to be output directly by Postgres Add a new GUC 'expose_information' that lets a small, fixed set of facts about the current server (recovery/role status, system identifier, and version) be queried over a plain HTTP request (GET or HEAD) on the same port PostgreSQL already listens on - without requiring authentication. This lets external tools have a way to quickly obtain information without requiring an account or ability to speak the protocol. expose_information takes a comma-separated list of options: role - whether the server is currently a primary or a replica version - the server's server_version_num sysid - the system identifier The GUC defaults to an empty string, so nothing is returned until explicitly enabled. It can be changed with a reload. When enabled, we check immediately after we fork by using MSG_PEEK to scan the first few bytes the client has sent. The socket is set non-blocking, and then restored to its original state. If the bytes match a small selection of strings, we handle it then and there: GET /replica, GET /primary, HEAD /replica, HEAD /primary, GET /version, and GET /sysid. Exact matches only: GET /versionx will not work. A minimal HTTP/1.1 response is sent back, the connection is closed, and the backend exits immediately. The outgoing message has a timeout to prevent the client from keeping the connection open. The HEAD /replica and HEAD /primary are meant to be drop in replacements for the Patroni REST API items. They return an HTTP code (200 for true, 503 for false), with no content. This allows for a lightweight health check, without requiring creation of an account and other overhead. The GET /version was designed to replace the common practice of monitoring systems that send a bad login message to the server, and use the debugging information about what line in our source code triggered the error as a very rough indication as to what version the Postgres server is running. Rather than all those workaround, they can simply ask the server with a quick HTTP request. If the bytes we examine do not match anything, they are left untouched in the kernel socket buffer and control falls back to the normal startup packet / authentication flow, so all connections are unaffected even if expose_information is set. A new TAP test, t/016_expose_information.pl, checks the output of calls to the TCP socket for all the known endpoints, including for servers in primary or replica mode. Also verify that normal libpq connections still work. --- doc/src/sgml/config.sgml | 62 ++++ src/backend/tcop/backend_startup.c | 334 ++++++++++++++++++ src/backend/utils/misc/guc_parameters.dat | 10 + src/backend/utils/misc/postgresql.conf.sample | 7 + src/include/tcop/backend_startup.h | 2 + src/include/utils/guc_hooks.h | 2 + src/test/modules/test_misc/meson.build | 1 + .../test_misc/t/016_expose_information.pl | 181 ++++++++++ 8 files changed, 599 insertions(+) create mode 100644 src/test/modules/test_misc/t/016_expose_information.pl diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 0165eb9ec02..7977ee252cc 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -661,6 +661,68 @@ include_dir 'conf.d' + + expose_information (string) + + expose_information configuration parameter + + + + + Allows for specific information to be returned from the servers without + requiring a login. Requests should come in as a simple HTTP request as a + GET or HEAD to the PostgreSQL port. + The default is the empty string, '', which + prevents any information from being output. The following options may be + specified alone or in a comma-separated list: + + + + Expose Information Options + + + + + + Name + Description + + + + + role + Determine if the server is in recovery mode or not. If the request is + HEAD /replica, then an HTTP response code of 200 (yes it is a replica) + or 503 (not a replica) is returned. A request of HEAD /primary + returns the opposite values. This can be used as a drop-in replacement for the same + functionality provided by the Patroni program. For the request GET /replica + and GET /primary the strings 1 or 0 + is returned. + + + + sysid + Returns the system identifier of the server. This can be useful to determine if the underlying + server has changed, as the initdb program will always generate a new system identifier. + For the request GET /sysid the string 12345 + is returned, in which "12345" will be the specific system identifier + (typically a 20-digit number) + + + + version + Returns the current version of the server. Specifically, the value of + server_version_num. For the request GET /version + the string 200002 is returned (for this example, + the version of Postgres is 20.2) + + + +
+ +
+
+ listen_addresses (string) diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c index 912ad7dc957..a03af3d20ac 100644 --- a/src/backend/tcop/backend_startup.c +++ b/src/backend/tcop/backend_startup.c @@ -46,6 +46,39 @@ bool Trace_connection_negotiation = false; uint32 log_connections = 0; char *log_connections_string = NULL; +uint32 expose_information = 0; +char *expose_information_string = NULL; + +/* Expose information bitmap */ +#define EXPOSE_INFO_ROLE 1 +#define EXPOSE_INFO_SYSID 2 +#define EXPOSE_INFO_VERSION 4 + +#define EXPOSE_MIN_QUERY 10 /* Shortest possible line: "GET /sysid" */ +#define EXPOSE_MAX_QUERY 16 /* Longest possible GET line */ + +#define EXPOSE_SEND_TIMEOUT_MS 2000 /* Maximum time to wait for clients to + * read info (milliseconds) */ +#define EXPOSE_SEND_RETRY_SLEEP_US 10000 /* How long to sleep between + * send() calls (microseconds) */ + +typedef enum +{ + EXPOSE_TYPE_NOTHING, + EXPOSE_TYPE_HEAD_REPLICA, + EXPOSE_TYPE_GET_REPLICA, + EXPOSE_TYPE_HEAD_PRIMARY, + EXPOSE_TYPE_GET_PRIMARY, + EXPOSE_TYPE_GET_SYSID, + EXPOSE_TYPE_GET_VERSION, +} ExposeReturnType; + +typedef struct +{ + const char *endpoint; + int require; + ExposeReturnType type; +} ExposeEndpointAction; /* Other globals */ @@ -65,6 +98,7 @@ static void SendNegotiateProtocolVersion(List *unrecognized_protocol_options); static void process_startup_packet_die(SIGNAL_ARGS); static void StartupPacketTimeoutHandler(void); static bool validate_log_connections_options(List *elemlist, uint32 *flags); +static bool ExposeInformation(pgsocket fd); /* * Entry point for a new backend process. @@ -148,6 +182,15 @@ BackendInitialize(ClientSocket *client_sock, CAC_state cac) StringInfoData ps_data; MemoryContext oldcontext; + /* + * Scan for a simple GET / HEAD request. If this is detected and handled, + * we are done and can immediately exit. + */ + if ((expose_information > 0) + && ExposeInformation(client_sock->sock)) + _exit(0); /* Safe to use exit: no state or resources + * created yet */ + /* Tell fd.c about the long-lived FD associated with the client_sock */ ReserveExternalFD(); @@ -1108,6 +1151,72 @@ next: ; } +/* + * GUC check_hook for expose_information + */ +bool +check_expose_information(char **newval, void **extra, GucSource source) +{ + char *rawstring; + List *elemlist; + ListCell *l; + int newexpose = 0; + int *myextra; + + /* Need a modifiable copy of string */ + rawstring = pstrdup(*newval); + + /* Parse string into list of identifiers */ + if (!SplitIdentifierString(rawstring, ',', &elemlist)) + { + /* syntax error in list */ + GUC_check_errdetail("List syntax is invalid."); + pfree(rawstring); + list_free(elemlist); + return false; + } + + foreach(l, elemlist) + { + char *tok = (char *) lfirst(l); + + if (pg_strcasecmp(tok, "role") == 0) + newexpose |= EXPOSE_INFO_ROLE; + else if (pg_strcasecmp(tok, "sysid") == 0) + newexpose |= EXPOSE_INFO_SYSID; + else if (pg_strcasecmp(tok, "version") == 0) + newexpose |= EXPOSE_INFO_VERSION; + else + { + GUC_check_errdetail("Unrecognized key word: \"%s\".", tok); + pfree(rawstring); + list_free(elemlist); + return false; + } + } + + pfree(rawstring); + list_free(elemlist); + + myextra = (int *) guc_malloc(LOG, sizeof(int)); + if (!myextra) + return false; + *myextra = newexpose; + *extra = myextra; + + return true; +} + +/* + * GUC assign_hook for expose_information + */ +void +assign_expose_information(const char *newval, void *extra) +{ + expose_information = *((int *) extra); +} + + /* * GUC check hook for log_connections */ @@ -1160,3 +1269,228 @@ assign_log_connections(const char *newval, void *extra) { log_connections = *((int *) extra); } + +/* + * ExposeInformation + * + * Handle early socket probe before full backend startup. + * Responds to small set of predefined endpoints (e.g. GET /replica) + * + * Requires the expose_information GUC to be non-empty + * + * Returns true if any endpoint is recognized. + */ + +static bool +ExposeInformation(pgsocket fd) +{ + ssize_t n; + char buf[EXPOSE_MAX_QUERY + 1]; + ExposeReturnType type; + bool result = false; +#ifdef WIN32 + int save_win32_noblock = pgwin32_noblock; +#endif + + /* Matching string, required setting, type of response */ + static const ExposeEndpointAction endpoint_actions[] = + { + { + "HEAD /replica", EXPOSE_INFO_ROLE, EXPOSE_TYPE_HEAD_REPLICA + }, + { + "GET /replica", EXPOSE_INFO_ROLE, EXPOSE_TYPE_GET_REPLICA + }, + { + "HEAD /primary", EXPOSE_INFO_ROLE, EXPOSE_TYPE_HEAD_PRIMARY + }, + { + "GET /primary", EXPOSE_INFO_ROLE, EXPOSE_TYPE_GET_PRIMARY + }, + { + "GET /sysid", EXPOSE_INFO_SYSID, EXPOSE_TYPE_GET_SYSID + }, + { + "GET /version", EXPOSE_INFO_VERSION, EXPOSE_TYPE_GET_VERSION + }, + }; + + Assert(expose_information > 0); + +#ifdef WIN32 + pgwin32_noblock = true; +#else + if (!pg_set_noblock(fd)) + goto cleanup; +#endif + + do + { + n = recv(fd, buf, EXPOSE_MAX_QUERY, MSG_PEEK); + } while (n < 0 && errno == EINTR); + + /* + * If there was a problem (n == -1), or the input is too short, we simply + * leave and let the normal flow continue. + */ + if (n < EXPOSE_MIN_QUERY) + goto cleanup; + + buf[n] = '\0'; + + type = EXPOSE_TYPE_NOTHING; + for (int i = 0; i < lengthof(endpoint_actions); i++) + { + size_t endpoint_len = strlen(endpoint_actions[i].endpoint); + + if ( + (expose_information & endpoint_actions[i].require) + && + strncmp(buf, endpoint_actions[i].endpoint, endpoint_len) == 0 + && + (buf[endpoint_len] == ' ' || buf[endpoint_len] == '\r' || buf[endpoint_len] == '\0') + ) + { + type = endpoint_actions[i].type; + break; + } + } + if (type == EXPOSE_TYPE_NOTHING) + goto cleanup; + + /* From this point onwards, we return true, as we have found a match */ + result = true; + + { + static const char http_version[] = "HTTP/1.1"; + static const char http_type[] = "Content-Type: text/plain"; + static const char http_conn[] = "Connection: close"; + static const char http_len[] = "Content-Length"; + + /* Total bytes of above: 8 + 24 + 17 + 14 = 63 bytes */ + + StringInfoData msg; + + TimestampTz start_time = 0; + size_t sent = 0; + bool send_failed = false; + + if (type == EXPOSE_TYPE_HEAD_REPLICA || type == EXPOSE_TYPE_HEAD_PRIMARY) + { + /* + * Caller only cares about the HTTP response code, so no content + * needed + */ + + bool recovery_in_progress = RecoveryInProgress(); + + initStringInfoExt(&msg, 90); + + appendStringInfo(&msg, + "%s %s\r\n" + "%s\r\n" + "%s\r\n\r\n", + http_version, + (((recovery_in_progress && type == EXPOSE_TYPE_HEAD_REPLICA) + || (!recovery_in_progress && type == EXPOSE_TYPE_HEAD_PRIMARY)) + ? "200 OK" : "503 Service Unavailable"), + http_type, + http_conn + ); + } + else + { + StringInfoData content; + + initStringInfoExt(&content, MAXINT8LEN + 3); + + switch (type) + { + + case EXPOSE_TYPE_GET_SYSID: + appendStringInfo(&content, UINT64_FORMAT "\r\n", + GetSystemIdentifier()); + break; + case EXPOSE_TYPE_GET_VERSION: + appendStringInfo(&content, "%d\r\n", + PG_VERSION_NUM); + break; + case EXPOSE_TYPE_GET_REPLICA: + appendStringInfo(&content, "%d\r\n", + RecoveryInProgress() ? 1 : 0); + break; + case EXPOSE_TYPE_GET_PRIMARY: + appendStringInfo(&content, "%d\r\n", + RecoveryInProgress() ? 0 : 1); + break; + default: + elog(ERROR, "unrecognized ExposeReturnType: %d", (int) type); + } + + initStringInfoExt(&msg, 128); + appendStringInfo(&msg, + "%s 200 OK\r\n" + "%s\r\n" + "%s: %d\r\n" + "%s\r\n\r\n" + "%s", + http_version, + http_type, + http_len, content.len, + http_conn, + content.data + ); + + pfree(content.data); + } + + /* + * Send the response. As this is at an early and important point, we + * want to quickly close the connection if the client stops reading, + * so we add a timeout. + */ + + while (sent < (size_t) msg.len && !send_failed) + { + + n = send(fd, msg.data + sent, msg.len - sent, 0); + + if (n > 0) + { + sent += n; + continue; + } + + /* While n==0 should not happen, we treat as a retry as well */ + if (n == 0 || + (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))) + { + if (start_time == 0) + start_time = GetCurrentTimestamp(); + else if (TimestampDifferenceExceeds(start_time, GetCurrentTimestamp(), + EXPOSE_SEND_TIMEOUT_MS)) + send_failed = true; + else + pg_usleep(EXPOSE_SEND_RETRY_SLEEP_US); + + continue; + } + + /* The send() call failed in some way we cannot handle */ + elog(LOG, "failed to send information to client: %m"); + send_failed = true; + + } + + pfree(msg.data); + + } + +cleanup: +#ifdef WIN32 + pgwin32_noblock = save_win32_noblock; +#else + (void) pg_set_block(fd); +#endif + return result; +} diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index c57441f7d98..9fc86f961d7 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -1065,6 +1065,16 @@ boot_val => 'false', }, +{ name => 'expose_information', type => 'string', context => 'PGC_SIGHUP', group => 'CONN_AUTH_AUTH', + short_desc => 'Expose limited information without needing to login', + long_desc => 'Valid values are combinations of "role", "sysid", and "version"', + flags => 'GUC_LIST_INPUT', + variable => 'expose_information_string', + boot_val => '""', + check_hook => 'check_expose_information', + assign_hook => 'assign_expose_information', +}, + { name => 'extension_control_path', type => 'string', context => 'PGC_SUSET', group => 'CLIENT_CONN_OTHER', short_desc => 'Sets the path for extension control files.', long_desc => 'The remaining extension script and secondary control files are then loaded from the same directory where the primary control file was found.', diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index e759f06b50f..2de210567f3 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -93,6 +93,13 @@ # disconnection while running queries; # 0 for never +# - Expose information - + +#expose_information = '' # comma-separated list of items to expose + # role = if the server is in recovery or not + # sysid = the current system identifier for this server + # version = the current version of this server + # - Authentication - #authentication_timeout = 1min # 1s-600s diff --git a/src/include/tcop/backend_startup.h b/src/include/tcop/backend_startup.h index d486f926319..feedc1f8a4e 100644 --- a/src/include/tcop/backend_startup.h +++ b/src/include/tcop/backend_startup.h @@ -20,6 +20,8 @@ extern PGDLLIMPORT bool Trace_connection_negotiation; extern PGDLLIMPORT uint32 log_connections; extern PGDLLIMPORT char *log_connections_string; +extern PGDLLIMPORT uint32 expose_information; +extern PGDLLIMPORT char *expose_information_string; /* Other globals */ extern PGDLLIMPORT struct ConnectionTiming conn_timing; diff --git a/src/include/utils/guc_hooks.h b/src/include/utils/guc_hooks.h index 06453a18c03..32df5bde220 100644 --- a/src/include/utils/guc_hooks.h +++ b/src/include/utils/guc_hooks.h @@ -61,6 +61,8 @@ extern void assign_default_text_search_config(const char *newval, void *extra); extern bool check_default_with_oids(bool *newval, void **extra, GucSource source); extern const char *show_effective_wal_level(void); +extern bool check_expose_information(char **newval, void **extra, GucSource source); +extern void assign_expose_information(const char *newval, void *extra); extern bool check_huge_page_size(int *newval, void **extra, GucSource source); extern void assign_io_method(int newval, void *extra); extern bool check_io_max_concurrency(int *newval, void **extra, GucSource source); diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build index 5d81f5b13be..7e9aeb0e804 100644 --- a/src/test/modules/test_misc/meson.build +++ b/src/test/modules/test_misc/meson.build @@ -24,6 +24,7 @@ tests += { 't/013_temp_obj_multisession.pl', 't/014_log_statement_max_length.pl', 't/015_temp_schema_exit_deferrable.pl', + 't/016_expose_information.pl', ], # The injection points are cluster-wide, so disable installcheck 'runningcheck': false, diff --git a/src/test/modules/test_misc/t/016_expose_information.pl b/src/test/modules/test_misc/t/016_expose_information.pl new file mode 100644 index 00000000000..747e8d9f82d --- /dev/null +++ b/src/test/modules/test_misc/t/016_expose_information.pl @@ -0,0 +1,181 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test gathering information before authentication via expose_* variables + +# Force use of TCP/IP sockets via raw_connect +INIT{ $PostgreSQL::Test::Utils::use_unix_sockets = 0; } + +use strict; +use warnings; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +plan tests => 31; + +my $node = PostgreSQL::Test::Cluster->new('node1'); + +# Set as logical here so we can restart it as a replica later +$node->init(allows_streaming => 'logical'); +$node->start; + +my $server_version = $node->safe_psql('postgres', 'show server_version_num'); +my $bindir = $node->config_data('--bindir'); +my $datadir = $node->data_dir; +my $cdata = qx{$bindir/pg_controldata -D $datadir 2>&1}; +my ($sysid) = $cdata =~ /Database system identifier:\s+(\d+)/; +my $receive_length = 200; + +my ($socket, $response, $test); + +sub call_socket { + my $string = shift; + $socket->close() if defined $socket; + $socket = $node->raw_connect(); + $socket->timeout(1); + $socket->send($string); + $response = ''; + select(undef, undef, undef, 0.1); + $socket->recv($response, $receive_length); + return; +} + +## Technically, this 'returns nothing' is a protocol error and a closed connection. +## But for purposes of this test, we just need to make sure nothing is returned. + +$test = q{GET /foobar returns nothing when expose_information = ''}; +call_socket('GET /foobar'); +is ($response, '', $test); + +$test = q{HEAD /foobar returns nothing when expose_information = ''}; +call_socket('HEAD /foobar'); +is ($response, '', $test); + +$test = q{GET /replica returns nothing when expose_information=''}; +call_socket('GET /replica'); +is ($response, '', $test); + +$test = q{HEAD /replica returns nothing when expose_information=''}; +call_socket('HEAD /replica'); +is ($response, '', $test); + +$test = q{GET /primary returns nothing when expose_information=''}; +call_socket('GET /primary'); +is ($response, '', $test); + +$test = q{HEAD /primary returns nothing when expose_information=''}; +call_socket('HEAD /primary'); +is ($response, '', $test); + +$node->append_conf('postgresql.conf', "expose_information = 'role'"); +$node->reload(); +## Some systems (WIN32) need a moment +sleep 1; + +$test = q{GET /replica returns HTTP code 200 when expose_information = 'role' (primary)}; +call_socket('GET /replica'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /replica returns string "0" when expose_information = 'role' (primary)}; +like ($response, qr{\r\n0\r\n}, $test); + +$test = q{HEAD /replica returns HTTP code 503 when expose_information = 'role' (primary)}; +call_socket('HEAD /replica'); +like ($response, qr{^HTTP/1.1 503 }, $test); + +$test = q{GET /primary returns HTTP code 200 when expose_information = 'role' (primary)}; +call_socket('GET /primary'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /primary returns string "1" when expose_information = 'role' (primary)}; +like ($response, qr{\r\n1\r\n}, $test); + +$test = q{HEAD /primary returns HTTP code 200 when expose_information = 'role' (primary)}; +call_socket('HEAD /primary'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /version returns nothing when expose_information = 'role'}; +call_socket('GET /version'); +is ($response, '', $test); + +$test = q{GET /sysid returns nothing when expose_information = 'role'}; +call_socket('GET /sysid'); +is ($response, '', $test); + +$node->append_conf('postgresql.conf', "expose_information= 'sysid'"); +$node->reload(); + +$test = q{GET /replica returns nothing when expose_information = 'sysid'}; +call_socket('GET /replica'); +is ($response, '', $test); + +$test = q{HEAD /replica returns nothing when expose_information = 'sysid'}; +call_socket('HEAD /replica'); +is ($response, '', $test); + +$node->append_conf('postgresql.conf', "expose_information= 'sysid,role,version'"); +$node->reload(); + +$test = q{GET /sysid returns correct value when expose_information contains 'sysid'}; +call_socket('GET /sysid'); +like ($response, qr/^$sysid\r\n/m, $test); + +$test = q{GET /version returns correct value when expose_information contains 'version'}; +call_socket('GET /version'); +like ($response, qr/^$server_version\r\n/m, $test); + +$test = q{GET /version\r\n returns correct value when expose_information contains 'version'}; +call_socket("GET /version\r\n"); +like ($response, qr/^$server_version\r\n/m, $test); + +$test = q{GET /version HTTP/1.0 returns correct value when expose_information contains 'version'}; +call_socket("GET /version HTTP/1.0"); +like ($response, qr/^$server_version\r\n/m, $test); + +$test = q{GET /ve returns nothing}; +call_socket('GET /ve'); +is ($response, '', $test); + +$test = q{GET /versio returns nothing}; +call_socket('GET /versio'); +is ($response, '', $test); + +$test = q{GET /versionx returns nothing}; +call_socket('GET /versionx'); +is ($response, '', $test); + +$node->set_standby_mode(); +$node->restart(); + +$test = q{GET /replica returns HTTP code 200 when expose_information contains 'role' (replica)}; +call_socket('GET /replica'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /replica returns string "1" when expose_information contains 'role' (replica)}; +like ($response, qr{^1\r\n}m, $test); + +$test = q{HEAD /replica returns HTTP code 200 when expose_information contains 'role' (replica)}; +call_socket('HEAD /replica'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /primary returns HTTP code 200 when expose_information contains 'role' (replica)}; +call_socket('GET /primary'); +like ($response, qr{^HTTP/1.1 200 }, $test); + +$test = q{GET /primary returns string "0" when expose_information contains 'role' (replica)}; +like ($response, qr{^0\r\n}m, $test); + +$test = q{HEAD /primary returns HTTP code 503 when expose_information contains 'role' (replica)}; +call_socket('HEAD /primary'); +like ($response, qr{^HTTP/1.1 503 }, $test); + +$test = q{Regular connection still works after expose_information is enabled}; +is ($node->safe_psql('postgres', 'select 42'), '42', $test); + +$node->append_conf('postgresql.conf', "expose_information=''"); +$node->reload(); + +$test = q{GET /version returns nothing after expose_information no longer has 'version'}; +call_socket('GET /version'); +is ($response, '', $test); + +$socket->close(); -- 2.47.3