Skip to content

Offline Mode: Reject external http requests when network is offline #1491

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: trunk
Choose a base branch
from

Conversation

gavande1
Copy link
Contributor

@gavande1 gavande1 commented Jun 19, 2025

Related issues

Fixes STU-550

My previous PR #1478, has following problems:

  • It required server restarts when the network status changed.
  • It updated the request timeout behaviour by restarting the server, which was not ideal.

Proposed Changes

I propose a solution of rejecting external requests when the network is offline. In this new approach, the network status is determined via a DNS query, which adds minimal overhead (~8ms). It doesn't require server restarts when the network status is changed.

How it Works
A custom header STUDIO_IS_OFFLINE is attached when the network is offline. If this header is detected in the mu-plugin, it intercepts all external requests and rejects them.

Why this change?
AFAIU, PHP WASM does not have native DNS resolution capability, so it cannot immediately detect offline status.

As a result:

  • Most external requests do not fail quickly. They hang until the full request timeout elapses.
  • Some fail with an SSL_ERROR_SYSCALL after a few seconds.

This is the primary reason why the WordPress admin is significantly slower in offline mode.

Native PHP has DNS resolution capability and can fail fast when a request cannot be resolved. Other tools, such as Local by Flywheel and Laravel Herd, since they use native PHP (with DNS resolution), offline requests fail quickly as expected; hence, they don't require additional handling for offline mode.

Testing Instructions

  • Check out this branch
  • Turn off the Wi-Fi adaptor or the network
  • Create a new site and observe the timing
  • Open WP Admin and observe the timing
  • Both of the above should be improved in comparison to the trunk. It should be almost instant.
  • Repeat the tests after connecting to the internet.
  • There should not be any difference in performance.

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?

@gavande1 gavande1 changed the title [Experiment 2] Reject external http requests when network is offline Offline Mode: Reject external http requests when network is offline Jun 19, 2025
@gavande1 gavande1 requested a review from a team June 19, 2025 17:31
@gavande1 gavande1 self-assigned this Jun 19, 2025
@gavande1 gavande1 marked this pull request as ready for review June 19, 2025 17:37
Copy link
Contributor

@epeicher epeicher left a comment

Choose a reason for hiding this comment

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

Thanks Rahul for improving this! I think the approach is good. Ideally, we would prefer that the queries failed fast as you mention in your description, but this simulates that behaviour through the http header, which works. I have tested it in Windows and Mac and it works as expected, it does not return any timeouts. LGTM!

Windows in offline mode

CleanShot.2025-06-20.at.12.51.40.mp4

Windows when turning on the internet connection

CleanShot.2025-06-20.at.12.53.26.mp4

Also, I would be interested in getting feedback from other team members

@gavande1 gavande1 requested a review from a team June 20, 2025 13:44
Copy link
Contributor

@bcotrim bcotrim left a comment

Choose a reason for hiding this comment

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

Nice one!
Really an improvement compared to the initial approach.
LGTM 👍

@wojtekn
Copy link
Contributor

wojtekn commented Jun 23, 2025

@adamziel, does this problem affect Playground in the browser or Playground CLI?

*/
export async function isOnline(hostname: string = 'google.com'): Promise<boolean> {
try {
await dns.promises.lookup(hostname);

Choose a reason for hiding this comment

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

any chance this would still be served from a dns cache when offline?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it is definitely possible. However, I haven't faced any issues during the development and testing of this PR, so I cannot confirm definitively.

@adamziel
Copy link

@wojtekn interesting! Playground CLI and web both go through this code path that disables network access during the installation:

https://github.com/WordPress/wordpress-playground/blob/43c560e374a0690e8a654404d9d4cc2e39d9175f/packages/playground/wordpress/src/boot.ts#L312-L365

As for the initial /wp-admin load, in the browser we use fetch() which fails immediately when offline. However, that could potentially be a problem in Playground CLI since it accesses the network using the same local WebSocket proxy as wp-now. Thank you for flagging this!

@adamziel
Copy link

adamziel commented Jun 24, 2025

Note while this PR solves the problem for wp_http_remote_get but probably not for curl or file_get_contents() requests.

AFAIU, PHP WASM does not have native DNS resolution capability, so it cannot immediately detect offline status.

As a result:

Most external requests do not fail quickly. They hang until the full request timeout elapses.
Some fail with an SSL_ERROR_SYSCALL after a few seconds.

Interesting! What's the specific causation link between not supporting the DNS resolution and the requests not failing quickly? I'm not sure how they're related, and we could support DNS resolution in Node if that's worthwhile.

Also, note that while PHP may not be DNS-aware, we still do DNS resolution in the outbound network proxy:

https://github.com/WordPress/wordpress-playground/blob/43c560e374a0690e8a654404d9d4cc2e39d9175f/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts#L202

If that doesn't fail quickly, it seems like a bug in that proxy. If it can be fixed there, it would solve for all possible ways of talking to the network.

@gavande1
Copy link
Contributor Author

gavande1 commented Jun 26, 2025

Note while this PR solves the problem for wp_http_remote_get but probably not for curl or file_get_contents() requests.

Thanks for flagging this, @adamziel. I didn't think about that. I think we should be able to set allow_url_fopen: '0' using ini_set to disable other native functionalities as it's done in installWordPress.

What's the specific causation link between not supporting the DNS resolution and the requests not failing quickly?

So, the following were my observations while setting up the site in offline mode. I added HTTP requests profiling in WordPress site using PLAYGROUND_INTERNAL_MU_PLUGINS_FOLDER to understand the request behaviour.

  • Some initial requests failed immediately with the SSL_ERROR_SYSCALL error.
  • Other requests timed out, taking the full duration of the configured request timeout, indicating that PHP was actually attempting the request and waiting for a response.
  • This led me to believe that PHP was indeed making real network requests, rather than short-circuiting them.
  • In certain cases, the requests were interrupted early by a DNS resolver. I didn't know about outbound-ws-to-tcp-proxy.ts, now I know, thanks to you. This returned the SSL_ERROR_SYSCALL error, causing the request to fail quickly.
  • However, some requests still went through the resolver and attempted resolution, possibly due to DNS caching, even though the network was offline.

Here are the actual logs, the request timeout was set to 30s:

[18-Jun-2025 17:10:23 UTC] HTTP Request Completed: api.wordpress.org/core/browse-happy/1.1/ - Duration: 211ms
[18-Jun-2025 17:10:23 UTC] HTTP Request Failed: api.wordpress.org/core/browse-happy/1.1/ - Error: cURL error 35: OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to api.wordpress.org:443 
[18-Jun-2025 17:10:23 UTC] HTTP Request Started: api.wordpress.org/core/serve-happy/1.0/
[18-Jun-2025 17:10:49 UTC] HTTP Request Started: api.wordpress.org/core/browse-happy/1.1/
[18-Jun-2025 17:10:53 UTC] HTTP Request: api.wordpress.org/core/serve-happy/1.0/ - Timeout: 30s
[18-Jun-2025 17:10:53 UTC] HTTP Request Completed: api.wordpress.org/core/serve-happy/1.0/ - Duration: 30009ms
[18-Jun-2025 17:10:53 UTC] HTTP Request Failed: api.wordpress.org/core/serve-happy/1.0/ - Error: cURL error 28: Operation timed out after 30005 milliseconds with 0 out of 0 bytes received
[18-Jun-2025 17:11:19 UTC] HTTP Request: api.wordpress.org/core/browse-happy/1.1/ - Timeout: 30s
[18-Jun-2025 17:11:19 UTC] HTTP Request Completed: api.wordpress.org/core/browse-happy/1.1/ - Duration: 30010ms
[18-Jun-2025 17:11:19 UTC] HTTP Request Failed: api.wordpress.org/core/browse-happy/1.1/ - Error: cURL error 28: Operation timed out after 30002 milliseconds with 0 out of 0 bytes received
[18-Jun-2025 17:11:19 UTC] HTTP Request Started: api.wordpress.org/core/serve-happy/1.0/
[18-Jun-2025 17:11:49 UTC] HTTP Request: api.wordpress.org/core/serve-happy/1.0/ - Timeout: 30s
[18-Jun-2025 17:11:49 UTC] HTTP Request Completed: api.wordpress.org/core/serve-happy/1.0/ - Duration: 30007ms
[18-Jun-2025 17:11:49 UTC] HTTP Request Failed: api.wordpress.org/core/serve-happy/1.0/ - Error: cURL error 28: Operation timed out after 30002 milliseconds with 0 out of 0 bytes received
[18-Jun-2025 17:11:50 UTC] HTTP Request Started: api.wordpress.org/events/1.0/
[18-Jun-2025 17:11:50 UTC] HTTP Request: api.wordpress.org/events/1.0/ - Timeout: 30s
[18-Jun-2025 17:11:50 UTC] HTTP Request Completed: api.wordpress.org/events/1.0/ - Duration: 279ms
[18-Jun-2025 17:11:50 UTC] HTTP Request Failed: api.wordpress.org/events/1.0/ - Error: cURL error 35: OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to api.wordpress.org:443 

Ideally, we want to fail these requests under ~20 ms as native PHP when we are offline. I hope this helps to understand where I am coming from.

If that doesn't fail quickly, it seems like a bug in that proxy. If it can be fixed there, it would solve for all possible ways of talking to the network.

Interesting. Could the local DNS cache cause this, or might there be another factor at play?
If it turns out to be a bug in the proxy and can be addressed there, that would significantly improve the overall experience.
I am happy to dig deeper into this if needed, though I would appreciate a bit of guidance to get started.

@adamziel
Copy link

I think we should be able to set allow_url_fopen: '0' using ini_set to disable other native functionalities as it's done in installWordPress.

Nice @gavande1! I'd just be careful to only do that for the installer, not for the entire site – many plugins use curl and fopen directly.

I am happy to dig deeper into this if needed, though I would appreciate a bit of guidance to get started.

Lovely! I'd start with checking how well @php-wasm/cli deals with network requests when offline. You can do it by cloning the Playground repo and running the following command:

PHP=8.0 node --experimental-strip-types --experimental-transform-types  --disable-warning=ExperimentalWarning --import ./packages/meta/src/node-es-module-loader/register.mts ./packages/php-wasm/cli/src/main.ts

^ it's mouthful and it's also the equivalent of just php. From there, you can use it to run a PHP script, e.g.

PHP=8.0 node --experimental-strip-types --experimental-transform-types  --disable-warning=ExperimentalWarning --import ./packages/meta/src/node-es-module-loader/register.mts ./packages/php-wasm/cli/src/main.ts myscript.php

In that script, you might try connecting to, say, https://w.org when your computer is offline. If it takes a long time, that's the issue right there. From there, I'd play with that proxy file I've linked to earlier and see if it, indeed, takes 30 seconds before it communicates the failure. If yes, that's where we can fix it. If not, we're back to the drawing board as the problem might be somewhere else.

You might also try it on node v23 with JSPI enabled – it changes how all async calls are handled:

PHP=8.0 node --experimental-wasm-jspi --experimental-strip-types --experimental-transform-types  --disable-warning=ExperimentalWarning --import ./packages/meta/src/node-es-module-loader/register.mts ./packages/php-wasm/cli/src/main.ts myscript.php

@adamziel
Copy link

adamziel commented Jun 26, 2025

@gavande1 I've played with this and this patch seems to be helping:

diff --git a/isomorphic-git b/isomorphic-git
--- a/isomorphic-git
+++ b/isomorphic-git
@@ -1 +1 @@
-Subproject commit cdca7e5dbf9bc4654eab3465ceab64a54ab30a76
+Subproject commit cdca7e5dbf9bc4654eab3465ceab64a54ab30a76-dirty
diff --git a/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts b/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts
index a1205bc252..7db7e936ea 100644
--- a/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts
+++ b/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts
@@ -207,6 +207,7 @@ async function onWsConnect(client: any, request: http.IncomingMessage) {
 			// Send empty binary data to notify requester that connection was
 			// initiated
 			client.send([]);
+			await new Promise(resolve => setTimeout(resolve, 0))
 			client.close(3000);
 			return;
 		}

This script seems to be consistently failing in 30 milliseconds:

<?php

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://wordpress.org');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
$time = microtime(true);
echo "Before curl exec\n";
$content = curl_exec($ch);
echo "After curl exec (".(microtime(true) - $time).")\n";

curl_close($ch);
var_dump(strlen($content));

Whereas without the await part, it sometimes fails and sometimes hangs until the 3 seconds timeout runs out.

One possible explanation is that PHP expects the socket to go through a certain state transition between "connection initiated" and "connection closed," and closing the connection synchronously doesn't give it a chance to notice that. Another explanation is that Node is doing some socket-level optimizations and skips a step if we close right after sending an empty buffer. Either way, skipping one event loop tick seems to remedy the problem.

Would you be willing to take that patch for a spin and confirm it helped? You could even edit the Studio node_modules/ directly to apply it in your local dev setup.

@gavande1
Copy link
Contributor Author

gavande1 commented Jun 26, 2025

Thanks for taking a look at it and playing around.

Would you be willing to take that patch for a spin and confirm it helped? You could even edit the Studio node_modules/ directly to apply it in your local dev setup.

Sure thing. Let me try and get back to you. Hoping for the best.

@gavande1
Copy link
Contributor Author

@adamziel I tested this briefly locally by applying a monkey patch. I didn't see any improvements in the Studio in offline mode. I will test it again tomorrow with a fresh mind and share detailed results.

@gavande1
Copy link
Contributor Author

gavande1 commented Jun 27, 2025

Hi @adamziel,

I tested the proposed changes by cloning the Playground repository and running it locally. As you mentioned, when using the PHP tester script directly, the PHP request fails immediately as expected:

PHP=8.0 node --experimental-strip-types --experimental-transform-types --disable-warning=ExperimentalWarning --import ./packages/meta/src/node-es-module-loader/register.mts ./packages/php-wasm/cli/src/main.ts myscript.php

However, when I try to run the same through Studio, it does not seem to reflect any change. I suspect I might be doing something wrong when trying to apply the patch in Studio.

It looks like I cannot directly modify outbound-ws-to-tcp-proxy.ts in node_modules since the source files are not included in the npm package—only the .wasm executables seem to be there (though I am not entirely sure).

I also tried pointing package.json to a local build of the Playground, instead of the published package:

"@php-wasm/node": "file:/Users/USER/projects/wordpress-playground/dist/packages/php-wasm/node",
"@php-wasm/scopes": "file:/Users/USER/projects/wordpress-playground/dist/packages/php-wasm/scopes",
"@php-wasm/universal": "file:/Users/USER/projects/wordpress-playground/dist/packages/php-wasm/universal",
"@php-wasm/node-polyfills": "file:/Users/USER/projects/wordpress-playground/dist/packages/php-wasm/node-polyfills",

But that did not help either. The PHP requests in Studio still take the full timeout duration.

At this point, I am unsure whether my local changes are being applied correctly when running Studio with the patched version or if the patch is applied correctly, but requests fail as before. Do you have any suggestions on how I can confirm or debug this more effectively?

Thanks!

@adamziel
Copy link

adamziel commented Jun 27, 2025

@gavande1 the code is probably a part of @php-wasm/node/index.cjs and @php-wasm/node/index.js. If updating php-wasm/node dependency in Studio is easy, I could just merge that patch into trunk and tag a new release – LMK.

@gavande1
Copy link
Contributor Author

Thanks @adamziel for the tip. The code was indeed in index.cjs and index.js. I was able to apply the patch correctly and verify it. However, the requests are taking as long as they used to. The requests are not failing fast 😔.

I was looking at the inbound-tcp-to-ws-proxy.ts file as a counterpart (Assuming, please correct me). Do you think something is there that is causing the delay in failing the request?

@adamziel
Copy link

adamziel commented Jul 1, 2025

@gavande1 Oh, too bad that didn't help :( How can I build Studio locally? I'd like to reproduce the issue somehow. Btw, if you add a console.log() beside adding that patch, would the logged message show up in the terminal? It's a relatively easy way of verifying whether the updated code is running.

@gavande1
Copy link
Contributor Author

gavande1 commented Jul 2, 2025

How can I build Studio locally?

Hey @adamziel, here is how you can run Studio locally:

This will launch the Studio app locally. If you make changes to the Node code, you will need to run npm start again to see the updates. i.e. after modifying index.js or index.cjs.

If you add a console.log() beside the patch, will the message appear in the terminal?

Yes, I tested it with a console.log() statement. To see the output in the terminal, I had to set the DEV flag. The command I used was:

DEV=true npm start

@adamziel
Copy link

adamziel commented Jul 2, 2025

Thank you! I'm on a meetup this week. I'll look into it as much as I can, but it may go much slower until Monday.

@gavande1
Copy link
Contributor Author

gavande1 commented Jul 3, 2025

No problem. FYI, I have been trying to understand the more advanced aspects of the PHP wasm environment, such as how WebSocket communication works. But it seems too much to grasp at once. Do we have an architecture doc where I can learn more about the internals?

@adamziel
Copy link

adamziel commented Jul 3, 2025

@gavande1
Copy link
Contributor Author

gavande1 commented Jul 3, 2025

Thank you 👍

@gavande1
Copy link
Contributor Author

gavande1 commented Jul 9, 2025

@adamziel Just checking if you've had a chance to look at this. If not, no worries.

@adamziel
Copy link

adamziel commented Jul 9, 2025

@gavande1 I just took this for a spin. I didn't have enough time available to run it in Studio so I've continued with Playground CLI.

I've reproduced the slow admin loading – it happened in two scenarios:

  1. Calling target.end() failed and prevented client.close(3000)
  2. The IP address was resolved from the DNS cache and the proxy tried to engage in a TCP connection – and failed after a timeout

Here's a patch that solves for 1 and outputs detailed debug information to see if 2 might be the case on your computer. Would you please apply it and share the outcomes as well as the CLI output log? I'll be AFK until the end of the week, but @mho22 might be able to help while I'm away.

diff --git a/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts b/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts
index a1205bc252..af899cc309 100644
--- a/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts
+++ b/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts
@@ -15,7 +15,7 @@ import { WebSocketServer } from 'ws';
 import { debugLog } from './utils';
 
 function log(...args: any[]) {
-	debugLog('[WS Server]', ...args);
+	console.log('[WS Server]', ...args);
 }
 
 const lookup = util.promisify(dns.lookup);
@@ -120,7 +120,7 @@ export function initOutboundWebsocketProxyServer(
 async function onWsConnect(client: any, request: http.IncomingMessage) {
 	const clientAddr = client?._socket?.remoteAddress || client.url;
 	const clientLog = function (...args: any[]) {
-		log(' ' + clientAddr + ': ', ...args);
+		log(`[${new Date().getTime()}] ` + ' ' + clientAddr + ': ', ...args);
 	};
 
 	clientLog(
@@ -191,7 +191,9 @@ async function onWsConnect(client: any, request: http.IncomingMessage) {
 	} as any);
 	client.on('error', function (a: string | Buffer) {
 		clientLog('WebSocket client error: ' + a);
-		target.end();
+		if (target) {
+			target.end();
+		}
 	});
 
 	// Resolve the target host to an IP address if it isn't one already
@@ -204,10 +206,12 @@ async function onWsConnect(client: any, request: http.IncomingMessage) {
 			clientLog('resolved ' + reqTargetHost + ' -> ' + reqTargetIp);
 		} catch (e) {
 			clientLog("can't resolve " + reqTargetHost + ' due to:', e);
-			// Send empty binary data to notify requester that connection was
-			// initiated
 			client.send([]);
-			client.close(3000);
+			setTimeout(() => {
+				// Send empty binary data to notify requester that connection was
+				// initiated
+				client.close(3000);
+			})
 			return;
 		}
 	} else {
@@ -238,7 +238,12 @@ async function onWsConnect(client: any, request: http.IncomingMessage) {
 	});
 	target.on('error', function (e: any) {
 		clientLog('target connection error', e);
-		target.end();
-		client.close(3000);
+		setTimeout(() => {
+			client.send([]);
+			client.close(3000);
+			if (target) {
+				target.end();
+			}
+		})
 	});
 }

@mho22, here's the Playground CLI command I've used to trigger this. You need to run it once when online, and then it will also work when you go offline.

DEV=1 node --disable-warning=ExperimentalWarning --experimental-strip-types --experimental-transform-types --import ./packages/meta/src/node-es-module-loader/register.mts ./packages/playground/cli/src/cli.ts server --php=7.4 --wp=trunk

@gavande1
Copy link
Contributor Author

Thanks @adamziel for looking into it. I am going to give it a try today and share my findings later.

@gavande1
Copy link
Contributor Author

gavande1 commented Jul 10, 2025

@adamziel @mho22, I applied the changes into node_modules folder directly. I am afraid it doesn't seem to help in offline mode. I have attached the video and relevant logs.

CleanShot.2025-07-10.at.13.43.02.mp4
[WS Server] [1752135186582]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=127.0.0.1&port=8884
[WS Server] [1752135186582]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135186582]  127.0.0.1:  Opening a socket connection to 127.0.0.1:8884
[WS Server] [1752135186583]  127.0.0.1:  Connected to target
[WS Server] [1752135186584]  127.0.0.1:  flushing { commandType: 1 } <Buffer 01 50 4f 53 54 20 2f 77 70 2d 63 72 6f 6e 2e 70 68 70 3f 64 6f 69 6e 67 5f 77 70 5f 63 72 6f 6e 3d 31 37 35 32 31 33 35 31 38 36 2e 35 37 36 39 39 39 ... 235 more bytes>
[WS Server] Binding the WebSockets server to 127.0.0.1:52360...
[WS Server] [1752135186790]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135186790]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135186790]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135186791]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135186791]  127.0.0.1:  closing client 1
[WS Server] [1752135186792]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135216642]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135216642]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135216642]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135216642]  127.0.0.1:  WebSocket client disconnected: 1005 []
[WS Server] [1752135216648]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135216648]  127.0.0.1:  closing client 1
[WS Server] [1752135216648]  127.0.0.1:  target disconnected
[WS Server] [1752135216650]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135216653]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135216653]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135216653]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135216654]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135216654]  127.0.0.1:  closing client 1
[WS Server] [1752135216661]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135216776]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=80
[WS Server] [1752135216776]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135216776]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135216777]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135216778]  127.0.0.1:  closing client 1
[WS Server] [1752135216795]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135216795]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135216796]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135216796]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135216804]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135216804]  127.0.0.1:  closing client 1
[WS Server] [1752135216806]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135216808]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=80
[WS Server] [1752135216809]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135216809]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135216811]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135216811]  127.0.0.1:  closing client 1
[WS Server] [1752135216813]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135217256]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=127.0.0.1&port=8884
[WS Server] [1752135217256]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135217256]  127.0.0.1:  Opening a socket connection to 127.0.0.1:8884
[WS Server] [1752135217257]  127.0.0.1:  Connected to target
[WS Server] [1752135217262]  127.0.0.1:  flushing { commandType: 1 } <Buffer 01 50 4f 53 54 20 2f 77 70 2d 63 72 6f 6e 2e 70 68 70 3f 64 6f 69 6e 67 5f 77 70 5f 63 72 6f 6e 3d 31 37 35 32 31 33 35 32 31 37 2e 32 35 30 30 30 30 ... 235 more bytes>
[WS Server] Binding the WebSockets server to 127.0.0.1:52375...
[WS Server] Binding the WebSockets server to 127.0.0.1:52377...
[WS Server] [1752135217478]  127.0.0.1:  target disconnected
[WS Server] [1752135217749]  127.0.0.1:  WebSocket client disconnected: 1005 []
[WS Server] [1752135217768]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135217768]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135217768]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135217771]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135217771]  127.0.0.1:  closing client 1
[WS Server] [1752135217778]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135225769]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135225769]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135225769]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135225771]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135225771]  127.0.0.1:  closing client 1
[WS Server] [1752135225777]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135225985]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135225986]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135225986]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135225998]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135225998]  127.0.0.1:  closing client 1
[WS Server] [1752135225999]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135226319]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135226319]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135226319]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135226320]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135226320]  127.0.0.1:  closing client 1
[WS Server] [1752135226328]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] Binding the WebSockets server to 127.0.0.1:52390...
[WS Server] [1752135229163]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135229163]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135229163]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135229164]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135229164]  127.0.0.1:  closing client 1
[WS Server] [1752135229171]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135229174]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135229174]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135229174]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135229175]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135229175]  127.0.0.1:  closing client 1
[WS Server] [1752135229181]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752135229700]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752135229700]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752135229700]  127.0.0.1:  resolving api.wordpress.org... 
[WS Server] [1752135229701]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752135229701]  127.0.0.1:  closing client 1
[WS Server] [1752135229708]  127.0.0.1:  WebSocket client disconnected: 3000 []

If it helps in any way, I have uploaded the entire console output to the following gist.

https://gist.github.com/gavande1/086e3c24abb6086d5784f932f1163cc5

@mho22
Copy link

mho22 commented Jul 10, 2025

I am sorry @gavande1, I'm trying to reproduce the issue since two hours now but I can't. I followed those steps :

  1. git clone https://github.com/Automattic/studio.git
  2. git checkout add/improve-offline-performance
  3. npm install
  4. DEV=true npm start
  5. I create a website named "Hello world"
  6. I go to localhost:8881/wp-admin
  7. I fill in my credentials
  8. I refresh the wp-admin page multiple times

I have never faced any timing issue identical to yours.

Capture d’écran 2025-07-10 à 13 38 02

There is a RSS Error obviously but no freeze.

Skipping WordPress checksum verification - offline mode
Starting server for 'Hello world'
[WS Server] Binding the WebSockets server to 127.0.0.1:60315...
directory: /Users/mho/Studio/hello-world
mode: wordpress
php: 8.3
wp: latest
[WS Server]  127.0.0.1:  WebSocket connection from : 127.0.0.1 at URL /?host=127.0.0.1&port=8881
[WS Server]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server]  127.0.0.1:  Opening a socket connection to 127.0.0.1:8881
[WS Server]  127.0.0.1:  target connection error Error: connect ECONNREFUSED 127.0.0.1:8881
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1634:16) {
  errno: -61,
  code: 'ECONNREFUSED',
  syscall: 'connect',
  address: '127.0.0.1',
  port: 8881
}
[WS Server]  127.0.0.1:  WebSocket client disconnected: 3000 []
Server running at http://localhost:8881
Server started for 'Hello world'

There's probably a missing step.

@gavande1
Copy link
Contributor Author

gavande1 commented Jul 10, 2025

Hi @mho22, thanks for trying that out, and apologies for the misunderstanding. Could you please try the same on the trunk branch? This branch already fixes the issue by applying the fix in the Studio repository. But Adam and I were trying to fix the issue on upstream so that other projects can benefit from the fix as well.

@mho22
Copy link

mho22 commented Jul 10, 2025

Aha, silly me! I’ll try that now.

Edit: Ok! I see the difference now. Thanks. Let's dig into this.

@mho22
Copy link

mho22 commented Jul 10, 2025

Could you try this on your side?

node_modules/@php-wasm/node/index.cjs at line 124120 :

} catch (e) {
	clientLog("can't resolve " + reqTargetHost + ' due to:', e);
-	client.send([]);
	setTimeout(() => {
		// Send empty binary data to notify requester that connection was
		// initiated
+		client.send([]);
		client.close(3000);
	})
	return;
}

It may sound overly simple, but it's worth a try.

@gavande1
Copy link
Contributor Author

gavande1 commented Jul 10, 2025

I tried it and it worked for the first couple of requests, but then requests started to take full time to time out. I have attached the video of the behaviour. The last reload took a full 30 seconds.

CleanShot.2025-07-10.at.18.26.19.mp4

@mho22
Copy link

mho22 commented Jul 10, 2025

We did make some progress. I think what's happening now is related to multiple websocket servers bound at the same time.

[WS Server] Binding the WebSockets server to 127.0.0.1:63297...
[WS Server] Binding the WebSockets server to 127.0.0.1:63299...
[WS Server] Binding the WebSockets server to 127.0.0.1:63302...

I'm investigating.

@gavande1
Copy link
Contributor Author

Interesting observation. I also noticed that, but ignored it, thinking it might be due to multiple workers creating their own connections. But I think it makes sense now. Ideally, we would want a single WebSocket server, right?

@mho22
Copy link

mho22 commented Jul 11, 2025

I think I've found something : each instance of the Dashboard is trying to connect to the first created webserver. This is not problematic when you load the dashboard every, let's say, 5 seconds, one at a time. But when you load two dashboards at the same time, the first one will run normally because it reaches the webserver first, while the second does not and waits until timeout.

The following logs shows that :

[WS Server] Inbound Proxy Ws Server Port : 54125
[WS Server] Outbound Proxy Ws Server Port : 54126
[WS Server] Binding the WebSockets server to 127.0.0.1:54126...
[WS Server] Websocket listening 127.0.0.1:54126...
[WS Server] [1752227539846]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752227548241]  127.0.0.1:  Connection handled by server at 127.0.0.1:54030
[WS Server] [1752227548241]  127.0.0.1:  WebSocket connection from : 127.0.0.1:54129 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752227548241]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752227548242]  127.0.0.1:  resolving api.wordpress.org...
[WS Server] [1752227548243]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752227548250]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752227548254]  127.0.0.1:  Connection handled by server at 127.0.0.1:54030
[WS Server] [1752227548254]  127.0.0.1:  WebSocket connection from : 127.0.0.1:54130 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752227548254]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752227548254]  127.0.0.1:  resolving api.wordpress.org...
[WS Server] [1752227548255]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] [1752227548263]  127.0.0.1:  WebSocket client disconnected: 3000 []
[WS Server] [1752227548575]  127.0.0.1:  Connection handled by server at 127.0.0.1:54030
[WS Server] [1752227548575]  127.0.0.1:  WebSocket connection from : 127.0.0.1:54135 at URL /?host=api.wordpress.org&port=443
[WS Server] [1752227548575]  127.0.0.1:  Version undefined, subprotocol: binary
[WS Server] [1752227548575]  127.0.0.1:  resolving api.wordpress.org...
[WS Server] [1752227548576]  127.0.0.1:  can't resolve api.wordpress.org due to: Error: getaddrinfo ENOTFOUND api.wordpress.org
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26) {
  errno: -3008,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'api.wordpress.org'
}
[WS Server] Inbound Proxy Ws Server Port : 54136
[WS Server] Outbound Proxy Ws Server Port : 54137
[WS Server] Binding the WebSockets server to 127.0.0.1:54137...
[WS Server] Websocket listening 127.0.0.1:54137...
[WS Server] [1752227548857]  127.0.0.1:  WebSocket client disconnected: 3000 []

As you can see every call here are handled by Connection handled by server at 127.0.0.1:54030 while previous lines show that another webserver was created.

Starting server for 'Hello World'
[WS Server] Inbound Proxy Ws Server Port : 54029
[WS Server] Outbound Proxy Ws Server Port : 54030
[WS Server] Binding the WebSockets server to 127.0.0.1:54030...
[WS Server] Websocket listening 127.0.0.1:54030...
directory: /Users/mho/Studio/gekki-wirk
mode: wordpress
php: 8.3
wp: latest
Server running at http://localhost:8881
Server started for 'Hello World'

But the first one remains the 54030.

To log the Connection handled by server message I added these lines in onWsConnect :

  const serverHost = client?._socket?.localAddress;
  const serverPort = client?._socket?.localPort;
  clientLog(`Connection handled by server at ${serverHost}:${serverPort}`);

@mho22
Copy link

mho22 commented Jul 11, 2025

I also tried to comment out the entire websocket property in the withNetworking function and it looks like there's no more 30 seconds timeout :

// packages/php-wasm/node/src/lib/networking/with-networking.ts
async function withNetworking(phpModuleArgs = {}) {
  const [inboundProxyWsServerPort, outboundProxyWsServerPort] = await findFreePorts(2);
  log( "Inbound Proxy Ws Server Port : " + inboundProxyWsServerPort);
  log( "Outbound Proxy Ws Server Port : " + outboundProxyWsServerPort);
  const outboundNetworkProxyServer = await initOutboundWebsocketProxyServer(
    outboundProxyWsServerPort
  );
  return {
    ...phpModuleArgs,
    outboundNetworkProxyServer,
    // websocket: {
    //   ...phpModuleArgs["websocket"] || {},
    //   url: (_, host, port) => {
    //     const query = new URLSearchParams({
    //       host,
    //       port
    //     }).toString();
    //     return `ws://127.0.0.1:${outboundProxyWsServerPort}/?${query}`;
    //   },
    //   subprotocol: "binary",
      // decorator: addSocketOptionsSupportToWebSocketClass,
      // serverDecorator: addTCPServerToWebSocketServerClass.bind(
      //   null,
      //   inboundProxyWsServerPort
      // )
    // }
  };
}

@adamziel
Copy link

Without those lines, network requests won't go out at all - so there's no timeout but not in a useful way. The concurrency insight is interesting. I wonder if it's about the wasm server only using a single worker. I'll look into this again today

@mho22
Copy link

mho22 commented Jul 14, 2025

@adamziel Certainly, I was aware that commenting out those lines wasn't the solution, but I wanted to document that discovery. Let me know if I can help.

@adamziel
Copy link

Yes, and the documenting is very useful indeed as it narrows down possible causes. Thank you!

@adamziel
Copy link

I've started drafting a test harness for the outbound network proxy in WordPress/wordpress-playground#2370 and found one failing scenario and one scenario that times out. Concurrent connections, weirdly, work just fine. I'll get these tests to pass and share the findings.

@adamziel
Copy link

I traced it down to WordPress/wordpress-playground#2260 – a fix that landed in Playground 1.1.3 (Studio uses 1.1.0). That's why I couldn't reproduce this with Playground CLI. The network proxy patches are still relevant and I'll land them in core, but to fix the problem reported here it seems like you only have to update to the latest Playground packages – which you'll get for free as a part of migration to Playground CLI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants