Skip to content

Commit 66b3639

Browse files
committed
Smooth Studio provider startup polling
1 parent 6ec0045 commit 66b3639

7 files changed

Lines changed: 231 additions & 60 deletions

File tree

client/src/api/simulators.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import type {
99
SimulatorsResponse,
1010
} from "./types";
1111

12-
export async function listSimulators(): Promise<SimulatorMetadata[]> {
13-
const data = await apiRequest<SimulatorsResponse>("/api/simulators");
12+
export async function listSimulators(
13+
options: RequestInit = {},
14+
): Promise<SimulatorMetadata[]> {
15+
const data = await apiRequest<SimulatorsResponse>("/api/simulators", options);
1416
return data.simulators ?? [];
1517
}
1618

client/src/app/AppShell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export function AppShell({
212212
isLoading,
213213
refresh,
214214
simulators,
215-
} = useSimulatorList();
215+
} = useSimulatorList({ remote: remoteStream });
216216
const [debugVisible, setDebugVisible] = useState(false);
217217
const [hierarchyVisible, setHierarchyVisible] = useState(() =>
218218
readStoredFlag(HIERARCHY_VISIBLE_STORAGE_KEY),

client/src/features/simulators/useSimulatorList.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,64 @@
1-
import { startTransition, useEffect, useState } from "react";
1+
import { startTransition, useEffect, useRef, useState } from "react";
22

33
import { listSimulators } from "../../api/simulators";
44
import type { SimulatorMetadata } from "../../api/types";
55

6-
export function useSimulatorList() {
6+
const LOCAL_REFRESH_MS = 5000;
7+
const REMOTE_REFRESH_MS = 10000;
8+
const REMOTE_ERROR_REFRESH_MS = 15000;
9+
const REMOTE_REQUEST_TIMEOUT_MS = 12000;
10+
11+
interface UseSimulatorListOptions {
12+
remote?: boolean;
13+
}
14+
15+
export function useSimulatorList({
16+
remote = false,
17+
}: UseSimulatorListOptions = {}) {
718
const [simulators, setSimulators] = useState<SimulatorMetadata[]>([]);
819
const [isLoading, setIsLoading] = useState(true);
920
const [error, setError] = useState("");
21+
const inFlightRef = useRef(false);
22+
const lastLoadFailedRef = useRef(false);
1023

1124
async function loadSimulators(cancelled = false) {
25+
if (inFlightRef.current) {
26+
return;
27+
}
28+
inFlightRef.current = true;
29+
const controller =
30+
remote && typeof AbortController !== "undefined"
31+
? new AbortController()
32+
: null;
33+
const timeoutId = controller
34+
? window.setTimeout(() => controller.abort(), REMOTE_REQUEST_TIMEOUT_MS)
35+
: 0;
1236
try {
13-
const nextSimulators = await listSimulators();
37+
const nextSimulators = await listSimulators(
38+
controller ? { signal: controller.signal } : {},
39+
);
1440
if (cancelled) {
1541
return;
1642
}
1743
startTransition(() => setSimulators(nextSimulators));
1844
setError("");
45+
lastLoadFailedRef.current = false;
1946
} catch (loadError) {
2047
if (!cancelled) {
2148
setError(
22-
loadError instanceof Error
23-
? loadError.message
24-
: "Failed to load simulators.",
49+
loadError instanceof DOMException && loadError.name === "AbortError"
50+
? "Timed out waiting for provider."
51+
: loadError instanceof Error
52+
? loadError.message
53+
: "Failed to load simulators.",
2554
);
55+
lastLoadFailedRef.current = true;
2656
}
2757
} finally {
58+
if (timeoutId) {
59+
window.clearTimeout(timeoutId);
60+
}
61+
inFlightRef.current = false;
2862
if (!cancelled) {
2963
setIsLoading(false);
3064
}
@@ -37,17 +71,33 @@ export function useSimulatorList() {
3771

3872
useEffect(() => {
3973
let cancelled = false;
74+
let timeoutId = 0;
4075

41-
void loadSimulators();
42-
const intervalId = window.setInterval(() => {
43-
void loadSimulators(cancelled);
44-
}, 5000);
76+
const scheduleNext = () => {
77+
if (cancelled) {
78+
return;
79+
}
80+
const delay = remote
81+
? lastLoadFailedRef.current
82+
? REMOTE_ERROR_REFRESH_MS
83+
: REMOTE_REFRESH_MS
84+
: LOCAL_REFRESH_MS;
85+
timeoutId = window.setTimeout(run, delay);
86+
};
87+
88+
const run = () => {
89+
void loadSimulators(cancelled).finally(scheduleNext);
90+
};
91+
92+
run();
4593

4694
return () => {
4795
cancelled = true;
48-
clearInterval(intervalId);
96+
if (timeoutId) {
97+
window.clearTimeout(timeoutId);
98+
}
4999
};
50-
}, []);
100+
}, [remote]);
51101

52102
return {
53103
error,

client/src/features/stream/useLiveStream.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020

2121
const FPS_SAMPLE_INTERVAL_MS = 500;
2222
const CLIENT_TELEMETRY_INTERVAL_MS = 1000;
23+
const REMOTE_CLIENT_TELEMETRY_INTERVAL_MS = 5000;
2324

2425
interface UseLiveStreamOptions {
2526
canvasElement: HTMLCanvasElement | null;
@@ -276,15 +277,15 @@ export function useLiveStream({
276277
});
277278
};
278279

280+
const intervalMs = remote
281+
? REMOTE_CLIENT_TELEMETRY_INTERVAL_MS
282+
: CLIENT_TELEMETRY_INTERVAL_MS;
279283
postTelemetry();
280-
const intervalId = window.setInterval(
281-
postTelemetry,
282-
CLIENT_TELEMETRY_INTERVAL_MS,
283-
);
284+
const intervalId = window.setInterval(postTelemetry, intervalMs);
284285
return () => {
285286
window.clearInterval(intervalId);
286287
};
287-
}, [simulator?.udid]);
288+
}, [remote, simulator?.udid]);
288289

289290
return {
290291
deviceNaturalSize,

scripts/studio-provider-bridge.mjs

Lines changed: 121 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,27 @@ let localToken = process.env.SIMDECK_LOCAL_TOKEN || providerToken;
1717
const registerIntervalMs = Number(
1818
process.env.SIMDECK_PROVIDER_REGISTER_INTERVAL_MS || 15000,
1919
);
20+
const maxConcurrentRequests = Math.max(
21+
1,
22+
Number(process.env.SIMDECK_PROVIDER_MAX_CONCURRENT_REQUESTS || 8),
23+
);
24+
const proxyTimeoutMs = Math.max(
25+
1000,
26+
Number(process.env.SIMDECK_PROVIDER_PROXY_TIMEOUT_MS || 25000),
27+
);
28+
const simulatorListCacheTtlMs = Math.max(
29+
0,
30+
Number(process.env.SIMDECK_PROVIDER_SIMULATORS_CACHE_MS || 5000),
31+
);
2032
const providerId =
2133
process.env.SIMDECK_STUDIO_PROVIDER_ID || stableLocalProviderId();
2234

2335
let stopped = false;
2436
let lastRegisterAt = 0;
2537
let registered = false;
38+
const activeRequests = new Set();
39+
const responseCache = new Map();
40+
const inFlightCache = new Map();
2641

2742
if (isMainModule()) {
2843
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
@@ -47,12 +62,15 @@ if (isMainModule()) {
4762
localToken = providerToken;
4863
}
4964

50-
console.log(`[simdeck-provider-bridge] ${publicUrl}`);
51-
5265
await registerProvider();
66+
console.log(`[simdeck-provider-bridge] ${publicUrl}`);
5367

5468
while (!stopped) {
5569
try {
70+
if (activeRequests.size >= maxConcurrentRequests) {
71+
await Promise.race([...activeRequests, sleep(50)]);
72+
continue;
73+
}
5674
if (Date.now() - lastRegisterAt > registerIntervalMs) {
5775
await registerProvider();
5876
}
@@ -67,7 +85,7 @@ if (isMainModule()) {
6785
await sleep(250);
6886
continue;
6987
}
70-
await handleRequest(next.request);
88+
runProviderRequest(next.request);
7189
} catch (error) {
7290
console.error(
7391
`[simdeck-provider-bridge] ${error instanceof Error ? error.message : String(error)}`,
@@ -76,6 +94,9 @@ if (isMainModule()) {
7694
}
7795
}
7896
} finally {
97+
if (activeRequests.size > 0) {
98+
await Promise.allSettled(activeRequests);
99+
}
79100
if (registered) {
80101
await markProviderExpired();
81102
}
@@ -158,42 +179,10 @@ async function localProviderMetadata() {
158179

159180
async function handleRequest(request) {
160181
try {
161-
const target = new URL(request.path, `${localUrl}/`);
162-
if (!target.searchParams.has("simdeckToken")) {
163-
target.searchParams.set("simdeckToken", localToken);
164-
}
165-
const headers = new Headers(request.headers || {});
166-
headers.set("x-simdeck-token", localToken);
167-
headers.delete("host");
168-
headers.delete("content-length");
169-
const response = await fetch(target, {
170-
body: request.bodyBase64
171-
? Buffer.from(request.bodyBase64, "base64")
172-
: undefined,
173-
headers,
174-
method: request.method,
175-
});
176-
const responseHeaders = {};
177-
for (const [name, value] of response.headers.entries()) {
178-
const lower = name.toLowerCase();
179-
if (
180-
lower === "connection" ||
181-
lower === "content-encoding" ||
182-
lower === "content-length" ||
183-
lower === "transfer-encoding"
184-
) {
185-
continue;
186-
}
187-
responseHeaders[name] = value;
188-
}
189-
const responseBodyBase64 = Buffer.from(
190-
await response.arrayBuffer(),
191-
).toString("base64");
182+
const responsePayload = await cachedProxyResponse(request);
192183
await complete({
193184
requestId: request.id,
194-
responseStatus: response.status,
195-
responseHeaders,
196-
responseBodyBase64,
185+
...responsePayload,
197186
});
198187
} catch (error) {
199188
await complete({
@@ -203,11 +192,106 @@ async function handleRequest(request) {
203192
}
204193
}
205194

195+
function runProviderRequest(request) {
196+
const task = handleRequest(request).catch((error) => {
197+
console.error(
198+
`[simdeck-provider-bridge] request ${request.id} failed: ${error instanceof Error ? error.message : String(error)}`,
199+
);
200+
});
201+
activeRequests.add(task);
202+
task.finally(() => {
203+
activeRequests.delete(task);
204+
});
205+
}
206+
207+
async function cachedProxyResponse(request) {
208+
const cacheKey = cacheKeyForRequest(request);
209+
if (!cacheKey || simulatorListCacheTtlMs <= 0) {
210+
return proxyLocalRequest(request);
211+
}
212+
213+
const cached = responseCache.get(cacheKey);
214+
if (cached && Date.now() - cached.updatedAt <= simulatorListCacheTtlMs) {
215+
return cached.payload;
216+
}
217+
218+
const pending = inFlightCache.get(cacheKey);
219+
if (pending) {
220+
return pending;
221+
}
222+
223+
const pendingRequest = proxyLocalRequest(request)
224+
.then((payload) => {
225+
if (payload.responseStatus >= 200 && payload.responseStatus < 300) {
226+
responseCache.set(cacheKey, { payload, updatedAt: Date.now() });
227+
}
228+
return payload;
229+
})
230+
.finally(() => {
231+
inFlightCache.delete(cacheKey);
232+
});
233+
inFlightCache.set(cacheKey, pendingRequest);
234+
return pendingRequest;
235+
}
236+
237+
function cacheKeyForRequest(request) {
238+
if (request.method !== "GET") {
239+
return "";
240+
}
241+
const target = new URL(request.path, `${localUrl}/`);
242+
target.searchParams.delete("simdeckToken");
243+
if (target.pathname !== "/api/simulators") {
244+
return "";
245+
}
246+
return `${target.pathname}?${target.searchParams.toString()}`;
247+
}
248+
249+
async function proxyLocalRequest(request) {
250+
const target = new URL(request.path, `${localUrl}/`);
251+
if (!target.searchParams.has("simdeckToken")) {
252+
target.searchParams.set("simdeckToken", localToken);
253+
}
254+
const headers = new Headers(request.headers || {});
255+
headers.set("x-simdeck-token", localToken);
256+
headers.delete("host");
257+
headers.delete("content-length");
258+
const response = await fetch(target, {
259+
body: request.bodyBase64
260+
? Buffer.from(request.bodyBase64, "base64")
261+
: undefined,
262+
headers,
263+
method: request.method,
264+
signal: AbortSignal.timeout(proxyTimeoutMs),
265+
});
266+
const responseHeaders = {};
267+
for (const [name, value] of response.headers.entries()) {
268+
const lower = name.toLowerCase();
269+
if (
270+
lower === "connection" ||
271+
lower === "content-encoding" ||
272+
lower === "content-length" ||
273+
lower === "transfer-encoding"
274+
) {
275+
continue;
276+
}
277+
responseHeaders[name] = value;
278+
}
279+
const responseBodyBase64 = Buffer.from(await response.arrayBuffer()).toString(
280+
"base64",
281+
);
282+
return {
283+
responseStatus: response.status,
284+
responseHeaders,
285+
responseBodyBase64,
286+
};
287+
}
288+
206289
async function localJson(path) {
207290
const target = new URL(path, `${localUrl}/`);
208291
target.searchParams.set("simdeckToken", localToken);
209292
const response = await fetch(target, {
210293
headers: { "x-simdeck-token": localToken },
294+
signal: AbortSignal.timeout(Math.min(proxyTimeoutMs, 5000)),
211295
});
212296
if (!response.ok) {
213297
throw new Error(

0 commit comments

Comments
 (0)