Skip to content

Commit 25f8e08

Browse files
authored
Fix DTD freezes when opening projects, and EDT freezes when the theme changed and opening embedded DevTools (flutter#8479)
*(originally with flutter#8477 Summary - Fix frequent “Timed out waiting for DTD websocket to start” errors when opening projects. - Fix EDT freezes caused by: - Synchronous JxBrowser Engine initialization when opening embedded DevTools. - Blocking ExecutorService lifecycle on theme change (JDK 21 ExecutorService.close() awaits termination). Changes - DtdUtils - Refactor readyDtdService to be truly asynchronous using a shared ScheduledExecutorService for periodic polling (no blocking). - De-duplicate and cache the in-flight Future per Project to avoid redundant polling under concurrency. - Preserve an overall timeout (~20s by default). On timeout or error, complete exceptionally and clear the cache to allow subsequent retries. - EmbeddedJxBrowser.openEmbeddedTab: - Avoid initializing JxBrowser Engine on the EDT; show “installation in progress” UI until async init completes. - This removes the fallback compareAndSet(null, getEngine()) path that could call Engine.newInstance(...) on EDT and freeze it. - FlutterInitializer.sendThemeChangedEvent: - Send themeChanged asynchronously: remove .get() blocking; use a shared scheduler with debounce. - If DTD becomes ready later, the event is sent then; if it times out, gracefully skip with debug-level logging (no startup errors). - Use shared AppScheduledExecutor (AppExecutorUtil) instead of creating/closing a new executor per event. - Remove try-with-resources that triggers ExecutorService.close() on EDT with JDK 21. Notes - Added comments explaining why Engine initialization must not occur on EDT and how JDK 21 changed ExecutorService.close(). Fixes - Fixes flutter#7794 - Fixes flutter#8394 - Fixes flutter#8478 Testing - Manual: - Open Flutter projects; no error logs about DTD timeout. - Switch IDE themes repeatedly; no UI freeze. - Open DevTools (embedded) after clean startup and after JxBrowser installation completes; no EDT freeze, proper “installing...” UX during setup.
1 parent d478642 commit 25f8e08

File tree

5 files changed

+156
-75
lines changed

5 files changed

+156
-75
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Made dev release daily instead of weekly
1414
- Set the device selector component to opaque during its creation to avoid an unexpected background color (#8471)
1515
- Refactored `DeviceSelectorAction` and add rich icons to different platform devices (#8475)
16+
- Fix DTD freezes when opening projects, and EDT freezes when the theme is changed and opening embedded DevTools (#8477)
1617

1718
## 87.1.0
1819

src/io/flutter/FlutterInitializer.java

Lines changed: 51 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import com.intellij.openapi.vfs.VirtualFile;
3030
import com.intellij.util.concurrency.AppExecutorUtil;
3131
import com.intellij.util.messages.MessageBusConnection;
32-
import com.jetbrains.lang.dart.ide.toolingDaemon.DartToolingDaemonService;
3332
import de.roderick.weberknecht.WebSocketException;
3433
import io.flutter.android.IntelliJAndroidSdk;
3534
import io.flutter.bazel.WorkspaceCache;
@@ -59,8 +58,7 @@
5958

6059
import java.util.ArrayList;
6160
import java.util.List;
62-
import java.util.concurrent.ExecutionException;
63-
import java.util.concurrent.Executors;
61+
import java.util.concurrent.ScheduledExecutorService;
6462
import java.util.concurrent.TimeUnit;
6563
import java.util.concurrent.atomic.AtomicLong;
6664

@@ -78,6 +76,10 @@ public class FlutterInitializer extends FlutterProjectActivity {
7876

7977
private @NotNull AtomicLong lastScheduledThemeChangeTime = new AtomicLong();
8078

79+
// Shared scheduler to avoid creating/closing executors on EDT
80+
@NotNull
81+
private final ScheduledExecutorService scheduler = AppExecutorUtil.getAppScheduledExecutorService();
82+
8183
@Override
8284
public void executeProjectStartup(@NotNull Project project) {
8385
log().info("Executing Flutter plugin startup for project: " + project.getName());
@@ -253,57 +255,59 @@ private void sendThemeChangedEvent(@NotNull Project project) {
253255
lastScheduledThemeChangeTime.set(requestTime);
254256

255257
// Schedule event to be sent in a second if nothing more recent has come in.
256-
try (var executor = Executors.newSingleThreadScheduledExecutor()) {
257-
executor.schedule(() -> {
258-
if (lastScheduledThemeChangeTime.get() != requestTime) {
259-
// A more recent request has been set, so drop this request.
260-
return;
261-
}
258+
scheduler.schedule(() -> {
259+
if (lastScheduledThemeChangeTime.get() != requestTime) {
260+
// A more recent request has been set, so drop this request.
261+
return;
262+
}
262263

263-
final JsonObject params = new JsonObject();
264-
params.addProperty("eventKind", "themeChanged");
265-
params.addProperty("streamId", "Editor");
264+
final JsonObject params = new JsonObject();
265+
params.addProperty("eventKind", "themeChanged");
266+
params.addProperty("streamId", "Editor");
266267

267-
final JsonObject themeData = new JsonObject();
268-
final DevToolsUtils utils = new DevToolsUtils();
269-
themeData.addProperty("isDarkMode", Boolean.FALSE.equals(utils.getIsBackgroundBright()));
270-
themeData.addProperty("backgroundColor", utils.getColorHexCode());
271-
themeData.addProperty("fontSize", utils.getFontSize().intValue());
268+
final JsonObject themeData = new JsonObject();
269+
final DevToolsUtils utils = new DevToolsUtils();
270+
themeData.addProperty("isDarkMode", Boolean.FALSE.equals(utils.getIsBackgroundBright()));
271+
themeData.addProperty("backgroundColor", utils.getColorHexCode());
272+
themeData.addProperty("fontSize", utils.getFontSize().intValue());
272273

273-
final JsonObject eventData = new JsonObject();
274-
eventData.add("theme", themeData);
275-
params.add("eventData", eventData);
274+
final JsonObject eventData = new JsonObject();
275+
eventData.add("theme", themeData);
276+
params.add("eventData", eventData);
276277

277-
try {
278-
final DtdUtils dtdUtils = new DtdUtils();
279-
final DartToolingDaemonService dtdService = dtdUtils.readyDtdService(project).get();
278+
final DtdUtils dtdUtils = new DtdUtils();
279+
dtdUtils.readyDtdService(project)
280+
.thenAccept(dtdService -> {
280281
if (dtdService == null) {
281-
log().error("Unable to send theme changed event because DTD service is null");
282+
log().warn("Unable to send theme changed event because DTD service is null");
282283
return;
283284
}
284-
285-
dtdService.sendRequest("postEvent", params, false, object -> {
286-
JsonObject result = object.getAsJsonObject("result");
287-
if (result == null) {
288-
log().error("Theme changed event returned null result");
289-
return;
290-
}
291-
JsonPrimitive type = result.getAsJsonPrimitive("type");
292-
if (type == null) {
293-
log().error("Theme changed event result type is null");
294-
return;
295-
}
296-
if (!"Success".equals(type.getAsString())) {
297-
log().error("Theme changed event result: " + type.getAsString());
298-
}
299-
}
300-
);
301-
}
302-
catch (WebSocketException | InterruptedException | ExecutionException e) {
303-
log().error("Unable to send theme changed event", e);
304-
}
305-
}, 1, TimeUnit.SECONDS);
306-
}
285+
try {
286+
dtdService.sendRequest("postEvent", params, false, object -> {
287+
JsonObject result = object.getAsJsonObject("result");
288+
if (result == null) {
289+
log().error("Theme changed event returned null result");
290+
return;
291+
}
292+
JsonPrimitive type = result.getAsJsonPrimitive("type");
293+
if (type == null) {
294+
log().error("Theme changed event result type is null");
295+
return;
296+
}
297+
if (!"Success".equals(type.getAsString())) {
298+
log().error("Theme changed event result: " + type.getAsString());
299+
}
300+
});
301+
}
302+
catch (WebSocketException e) {
303+
log().error("Unable to send theme changed event", e);
304+
}
305+
})
306+
.exceptionally(e -> {
307+
log().debug("DTD not ready; skipping themeChanged event", e);
308+
return null;
309+
});
310+
}, 1, TimeUnit.SECONDS);
307311
}
308312

309313
private void checkSdkVersionNotification(@NotNull Project project) {

src/io/flutter/dart/DtdUtils.java

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,48 @@
66
package io.flutter.dart;
77

88
import com.intellij.openapi.project.Project;
9+
import com.intellij.util.concurrency.AppExecutorUtil;
910
import com.jetbrains.lang.dart.ide.toolingDaemon.DartToolingDaemonService;
1011
import org.jetbrains.annotations.NotNull;
1112

12-
import java.util.concurrent.CompletableFuture;
13+
import java.util.Map;
14+
import java.util.concurrent.*;
1315

1416
public class DtdUtils {
17+
private static final Map<Project, CompletableFuture<DartToolingDaemonService>> WAITERS = new ConcurrentHashMap<>();
18+
1519
public @NotNull CompletableFuture<DartToolingDaemonService> readyDtdService(@NotNull Project project) {
16-
final DartToolingDaemonService dtdService = DartToolingDaemonService.getInstance(project);
17-
CompletableFuture<DartToolingDaemonService> readyService = new CompletableFuture<>();
18-
int attemptsRemaining = 10;
19-
final int TIME_IN_BETWEEN = 2;
20-
while (attemptsRemaining > 0) {
21-
attemptsRemaining--;
20+
return WAITERS.computeIfAbsent(project, p -> {
21+
final DartToolingDaemonService dtdService = DartToolingDaemonService.getInstance(project);
22+
CompletableFuture<DartToolingDaemonService> readyService = new CompletableFuture<>();
23+
2224
if (dtdService.getWebSocketReady()) {
2325
readyService.complete(dtdService);
24-
break;
25-
}
26-
try {
27-
Thread.sleep(TIME_IN_BETWEEN * 1000);
26+
return readyService;
2827
}
29-
catch (InterruptedException e) {
30-
readyService.completeExceptionally(e);
31-
break;
32-
}
33-
}
34-
if (!readyService.isDone()) {
35-
readyService.completeExceptionally(new Exception("Timed out waiting for DTD websocket to start"));
36-
}
37-
return readyService;
28+
29+
final ScheduledExecutorService scheduler = AppExecutorUtil.getAppScheduledExecutorService();
30+
31+
final ScheduledFuture<?> poll = scheduler.scheduleWithFixedDelay(() -> {
32+
if (readyService.isDone()) return;
33+
if (dtdService.getWebSocketReady()) {
34+
readyService.complete(dtdService);
35+
}
36+
}, 0, 500, TimeUnit.MILLISECONDS);
37+
38+
final ScheduledFuture<?> timeout = scheduler.schedule(() -> {
39+
readyService.completeExceptionally(new Exception("Timed out waiting for DTD websocket to start"));
40+
}, 20, TimeUnit.SECONDS);
41+
42+
readyService.whenComplete((s, t) -> {
43+
poll.cancel(false);
44+
timeout.cancel(false);
45+
if (t != null) {
46+
WAITERS.remove(p);
47+
}
48+
});
49+
50+
return readyService;
51+
});
3852
}
3953
}

src/io/flutter/jxbrowser/EmbeddedJxBrowser.java

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ public class EmbeddedJxBrowser extends EmbeddedBrowser {
129129
"Waiting for JxBrowser installation timed out. Restart your IDE to try again.";
130130
private static final String INSTALLATION_WAIT_FAILED = "The JxBrowser installation failed unexpectedly. Restart your IDE to try again.";
131131
private static final int INSTALLATION_WAIT_LIMIT_SECONDS = 30;
132+
133+
@NotNull
132134
private final AtomicReference<Engine> engineRef = new AtomicReference<>(null);
133135

134136
private final Project project;
@@ -173,19 +175,22 @@ private EmbeddedJxBrowser(@NotNull Project project) {
173175
}
174176

175177
@Override
176-
public @Nullable EmbeddedTab openEmbeddedTab(ContentManager contentManager) {
178+
public @Nullable EmbeddedTab openEmbeddedTab(@NotNull ContentManager contentManager) {
177179
manageJxBrowserDownload(contentManager);
178-
if (engineRef.get() == null) {
179-
engineRef.compareAndSet(null, EmbeddedBrowserEngine.getInstance().getEngine());
180-
}
180+
// Do NOT initialize the JxBrowser Engine on the EDT.
181+
// Historically, we tried to 'fallback' and construct the Engine here when it's null:
182+
// engineRef.compareAndSet(null, EmbeddedBrowserEngine.getInstance().getEngine());
183+
// That path synchronously triggers Engine.newInstance(...) which blocks (CountDownLatch.await)
184+
// and can freeze the Event Dispatch Thread (see #8394).
185+
//
186+
// Proceed only when the engine has been initialized by the async installation callback.
181187
final Engine engine = engineRef.get();
182188
if (engine == null) {
183-
showMessageWithUrlLink(jxBrowserErrorMessage(), contentManager);
189+
// Show an "installation in progress" UX instead of attempting synchronous engine creation.
190+
handleJxBrowserInstallationInProgress(contentManager);
184191
return null;
185192
}
186-
else {
187-
return new EmbeddedJxBrowserTab(engine);
188-
}
193+
return new EmbeddedJxBrowserTab(engine);
189194
}
190195

191196
private @NotNull String jxBrowserErrorMessage() {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright 2016 The Chromium Authors. All rights reserved.
3+
* Use of this source code is governed by a BSD-style license that can be
4+
* found in the LICENSE file.
5+
*/
6+
package io.flutter;
7+
8+
import org.junit.Test;
9+
10+
import java.lang.reflect.Field;
11+
import java.util.concurrent.ScheduledExecutorService;
12+
import java.util.concurrent.atomic.AtomicLong;
13+
14+
import static org.junit.Assert.assertNotNull;
15+
import static org.junit.Assert.assertTrue;
16+
17+
/**
18+
* Tests for {@link FlutterInitializer}.
19+
*/
20+
public class FlutterInitializerTest {
21+
22+
@Test
23+
public void testInitializerCanBeCreated() {
24+
// Test that we can create FlutterInitializer without issues
25+
// This validates that the shared scheduler field is properly initialized
26+
FlutterInitializer initializer = new FlutterInitializer();
27+
assertNotNull("FlutterInitializer should be created successfully", initializer);
28+
}
29+
30+
@Test
31+
public void testSchedulerFieldExists() throws Exception {
32+
// Test that the scheduler field exists and is properly initialized
33+
FlutterInitializer initializer = new FlutterInitializer();
34+
35+
Field schedulerField = FlutterInitializer.class.getDeclaredField("scheduler");
36+
schedulerField.setAccessible(true);
37+
38+
Object scheduler = schedulerField.get(initializer);
39+
assertNotNull("Scheduler field should be initialized", scheduler);
40+
assertTrue("Scheduler should be a ScheduledExecutorService",
41+
scheduler instanceof ScheduledExecutorService);
42+
}
43+
44+
@Test
45+
public void testDebounceFieldExists() throws Exception {
46+
// Test that the debounce field exists and is properly initialized
47+
FlutterInitializer initializer = new FlutterInitializer();
48+
49+
Field debounceField = FlutterInitializer.class.getDeclaredField("lastScheduledThemeChangeTime");
50+
debounceField.setAccessible(true);
51+
52+
Object debounceTimer = debounceField.get(initializer);
53+
assertNotNull("Debounce timer field should be initialized", debounceTimer);
54+
assertTrue("Debounce timer should be an AtomicLong",
55+
debounceTimer instanceof AtomicLong);
56+
}
57+
}

0 commit comments

Comments
 (0)