Skip to content

Commit 858b84a

Browse files
committed
fix(browser): wait for current controller and runtime
1 parent 56dd6eb commit 858b84a

5 files changed

Lines changed: 197 additions & 30 deletions

File tree

packages/api/src/services/project-browser.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { PlatformError } from "@effect/platform/Error"
1010
import * as HttpServerError from "@effect/platform/HttpServerError"
1111
import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
1212
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
13-
import { Effect } from "effect"
13+
import { Duration, Effect, pipe, Schedule } from "effect"
1414
import * as Stream from "effect/Stream"
1515
import type { IncomingMessage, Server as HttpServer } from "node:http"
1616
import { createConnection, type Socket } from "node:net"
@@ -96,6 +96,7 @@ const cdpHostHeader = "127.0.0.1:9222"
9696
const browserActivityWriteIntervalMs = 30_000
9797
const browserActivityWrites = new Map<string, number>()
9898
const browserWebSocketCounts = new Map<string, number>()
99+
const browserRuntimeReadySchedule = Schedule.addDelay(Schedule.recurs(40), () => Duration.millis(250))
99100

100101
const hopByHopRequestHeaders = new Set([
101102
"connection",
@@ -248,6 +249,32 @@ const startBrowserContainer = (
248249
)
249250
)
250251

252+
const renderBrowserRuntimeReadyProbeScript = (): string => [
253+
"set -eu",
254+
`for port in ${browserNoVncPort} ${browserVncPort} ${browserCdpPort}; do`,
255+
" timeout 1 bash -lc \"</dev/tcp/127.0.0.1/${port}\"",
256+
"done"
257+
].join("\n")
258+
259+
const waitForBrowserRuntimeReady = (
260+
cwd: string,
261+
projectContainerName: string
262+
) =>
263+
pipe(
264+
dockerCapture(
265+
cwd,
266+
["exec", projectContainerName, "bash", "-lc", renderBrowserRuntimeReadyProbeScript()],
267+
"docker exec browser runtime ready probe"
268+
),
269+
Effect.asVoid,
270+
Effect.retry(browserRuntimeReadySchedule),
271+
Effect.mapError(() =>
272+
new ApiConflictError({
273+
message: `Browser runtime did not become ready for ${projectContainerName}.`
274+
})
275+
)
276+
)
277+
251278
const parseContainerNetworkEntries = (output: string): ReadonlyArray<ContainerNetworkEntry> =>
252279
output
253280
.trim()
@@ -439,6 +466,7 @@ export const startProjectBrowserSession = (
439466
const project = yield* _(getProjectItemById(projectId))
440467
const containerName = browserContainerName(project.containerName)
441468
yield* _(startBrowserContainer(project.projectDir, project.containerName))
469+
yield* _(waitForBrowserRuntimeReady(project.projectDir, project.containerName))
442470
const state = yield* _(inspectBrowserContainerState(project.projectDir, containerName))
443471
return browserSessionFromState(projectId, containerName, state, externalOrigin)
444472
})

packages/app/src/docker-git/controller-health.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ const probeHealth = (apiBaseUrl: string): Effect.Effect<HealthProbeResult, Contr
6464
)
6565

6666
const findReachableHealthProbe = (
67-
candidateUrls: ReadonlyArray<string>
67+
candidateUrls: ReadonlyArray<string>,
68+
expectedRevision?: string
6869
): Effect.Effect<HealthProbeResult, ControllerBootstrapError> =>
6970
Effect.gen(function*(_) {
7071
if (candidateUrls.length === 0) {
@@ -73,14 +74,19 @@ const findReachableHealthProbe = (
7374
)
7475
}
7576

77+
const mismatches: Array<string> = []
7678
for (const candidateUrl of candidateUrls) {
7779
const healthy = yield* _(probeHealth(candidateUrl).pipe(Effect.either))
78-
if (Either.isRight(healthy)) {
80+
if (Either.isLeft(healthy)) {
81+
continue
82+
}
83+
if (matchesExpectedRevision(healthy.right, expectedRevision)) {
7984
return healthy.right
8085
}
86+
mismatches.push(describeRevisionMismatch(healthy.right))
8187
}
8288

83-
return yield* _(Effect.fail(controllerBootstrapError("No docker-git controller endpoint responded to /health.")))
89+
return yield* _(Effect.fail(noMatchingHealthProbeError(expectedRevision, mismatches)))
8490
})
8591

8692
const findReachableHealthProbeOrNull = (
@@ -93,10 +99,45 @@ const findReachableHealthProbeOrNull = (
9399
})
94100
)
95101

102+
const matchesExpectedRevision = (
103+
probe: HealthProbeResult,
104+
expectedRevision: string | undefined
105+
): boolean => expectedRevision === undefined || probe.revision === expectedRevision
106+
107+
const describeRevisionMismatch = (probe: HealthProbeResult): string =>
108+
`${probe.apiBaseUrl} revision ${probe.revision ?? "unknown"}`
109+
110+
const noMatchingHealthProbeError = (
111+
expectedRevision: string | undefined,
112+
mismatches: ReadonlyArray<string>
113+
): ControllerBootstrapError =>
114+
expectedRevision !== undefined && mismatches.length > 0
115+
? controllerBootstrapError(
116+
`No docker-git controller endpoint with revision ${expectedRevision} responded. ` +
117+
`Reachable mismatched controllers: ${mismatches.join(", ")}.`
118+
)
119+
: controllerBootstrapError("No docker-git controller endpoint responded to /health.")
120+
96121
export const findReachableApiBaseUrl = (
97-
candidateUrls: ReadonlyArray<string>
122+
candidateUrls: ReadonlyArray<string>,
123+
expectedRevision?: string
98124
): Effect.Effect<string, ControllerBootstrapError> =>
99-
findReachableHealthProbe(candidateUrls).pipe(Effect.map(({ apiBaseUrl }) => apiBaseUrl))
125+
findReachableHealthProbe(candidateUrls, expectedRevision).pipe(Effect.map(({ apiBaseUrl }) => apiBaseUrl))
126+
127+
// CHANGE: select only controller endpoints that prove the expected source revision.
128+
// WHY: containerized hosts can see stale controllers through host.docker.internal before the current local controller is reachable.
129+
// QUOTE(ТЗ): "проверь сам что Open Browser кнопка работает"
130+
// REF: user-message-2026-05-29-open-browser-e2e
131+
// SOURCE: n/a
132+
// FORMAT THEOREM: selected(endpoint) -> health(endpoint).revision = expectedRevision
133+
// PURITY: SHELL
134+
// EFFECT: FetchHttpClient health probes.
135+
// INVARIANT: mismatched reachable controllers are rejected rather than reused.
136+
// COMPLEXITY: O(n) health probes where n = |candidateUrls|.
137+
export const findReachableApiBaseUrlMatchingRevision = (
138+
candidateUrls: ReadonlyArray<string>,
139+
expectedRevision: string
140+
): Effect.Effect<string, ControllerBootstrapError> => findReachableApiBaseUrl(candidateUrls, expectedRevision)
100141

101142
export const findReachableDirectHealthProbe = (options: {
102143
readonly explicitApiBaseUrl: string | undefined
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Effect } from "effect"
2+
3+
import * as ControllerDocker from "./controller-docker.js"
4+
import { type DockerNetworkIps, formatNetworkIps } from "./controller-reachability.js"
5+
6+
export const collectReachabilityDiagnostics = (
7+
candidateUrls: ReadonlyArray<string>,
8+
currentContainerNetworks: DockerNetworkIps,
9+
controllerNetworks: DockerNetworkIps
10+
): Effect.Effect<string, never, ControllerDocker.ControllerRuntime> =>
11+
Effect.gen(function*(_) {
12+
const publishedPorts = yield* _(ControllerDocker.inspectControllerPublishedPorts())
13+
14+
return [
15+
"Tried endpoints:",
16+
...candidateUrls.map((candidateUrl) => `- ${candidateUrl}`),
17+
`Published ports: ${publishedPorts.length > 0 ? publishedPorts : "unavailable"}`,
18+
`Current runtime networks: ${formatNetworkIps(currentContainerNetworks)}`,
19+
`Controller networks: ${formatNetworkIps(controllerNetworks)}`
20+
].join("\n")
21+
})

packages/app/src/docker-git/controller.ts

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import { resolveControllerComposeUpArgs, shouldBuildControllerImage } from "./co
44
import * as ControllerDocker from "./controller-docker.js"
55
import { findReachableApiBaseUrl, findReachableDirectHealthProbe } from "./controller-health.js"
66
import { inspectControllerImageRevision } from "./controller-image-revision.js"
7+
import { collectReachabilityDiagnostics } from "./controller-reachability-diagnostics.js"
78
import {
89
buildApiBaseUrlCandidates,
910
type DockerNetworkIps,
10-
formatNetworkIps,
1111
resolveApiPort,
1212
resolveConfiguredApiBaseUrl,
1313
resolveDefaultLocalApiBaseUrl,
@@ -42,30 +42,14 @@ const rememberSelectedApiBaseUrl = (value: string): void => {
4242
export const resolveApiBaseUrl = (): string =>
4343
resolveExplicitApiBaseUrl() ?? selectedApiBaseUrl ?? resolveConfiguredApiBaseUrl()
4444

45-
const collectReachabilityDiagnostics = (
46-
candidateUrls: ReadonlyArray<string>,
47-
currentContainerNetworks: DockerNetworkIps,
48-
controllerNetworks: DockerNetworkIps
49-
): Effect.Effect<string, never, ControllerDocker.ControllerRuntime> =>
50-
Effect.gen(function*(_) {
51-
const publishedPorts = yield* _(ControllerDocker.inspectControllerPublishedPorts())
52-
53-
return [
54-
"Tried endpoints:",
55-
...candidateUrls.map((candidateUrl) => `- ${candidateUrl}`),
56-
`Published ports: ${publishedPorts.length > 0 ? publishedPorts : "unavailable"}`,
57-
`Current runtime networks: ${formatNetworkIps(currentContainerNetworks)}`,
58-
`Controller networks: ${formatNetworkIps(controllerNetworks)}`
59-
].join("\n")
60-
})
61-
6245
const waitForReachableApiBaseUrl = (
6346
candidateUrls: ReadonlyArray<string>,
6447
currentContainerNetworks: DockerNetworkIps,
65-
controllerNetworks: DockerNetworkIps
48+
controllerNetworks: DockerNetworkIps,
49+
expectedRevision: string | undefined
6650
): ControllerEffect<string> =>
6751
pipe(
68-
findReachableApiBaseUrl(candidateUrls),
52+
findReachableApiBaseUrl(candidateUrls, expectedRevision),
6953
Effect.retry(
7054
Schedule.addDelay(Schedule.recurs(30), () => Duration.seconds(2))
7155
),
@@ -118,9 +102,10 @@ const failIfRemoteDockerWithoutApiUrl = (
118102
}
119103

120104
const findReachableApiBaseUrlOrNull = (
121-
candidateUrls: ReadonlyArray<string>
105+
candidateUrls: ReadonlyArray<string>,
106+
expectedRevision: string | undefined
122107
): Effect.Effect<string | null> =>
123-
findReachableApiBaseUrl(candidateUrls).pipe(
108+
findReachableApiBaseUrl(candidateUrls, expectedRevision).pipe(
124109
Effect.match({
125110
onFailure: () => null,
126111
onSuccess: (apiBaseUrl) => apiBaseUrl
@@ -209,7 +194,8 @@ const reuseReachableControllerIfPossible = (
209194
context.explicitApiBaseUrl,
210195
context.currentContainerNetworks,
211196
context.initialControllerNetworks
212-
)
197+
),
198+
context.explicitApiBaseUrl === undefined ? context.localControllerRevision : undefined
213199
).pipe(
214200
Effect.map((reachableApiBaseUrl) => {
215201
if (reachableApiBaseUrl === null || context.forceRecreateController) {
@@ -252,7 +238,12 @@ const startAndRememberController = (
252238
controllerNetworks
253239
)
254240
const reachableApiBaseUrl = yield* _(
255-
waitForReachableApiBaseUrl(candidateUrls, context.currentContainerNetworks, controllerNetworks)
241+
waitForReachableApiBaseUrl(
242+
candidateUrls,
243+
context.currentContainerNetworks,
244+
controllerNetworks,
245+
context.explicitApiBaseUrl === undefined ? context.localControllerRevision : undefined
246+
)
256247
)
257248
rememberSelectedApiBaseUrl(reachableApiBaseUrl)
258249
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
import { Effect } from "effect"
3+
import { vi } from "vitest"
4+
5+
import { findReachableApiBaseUrlMatchingRevision } from "../../src/docker-git/controller-health.js"
6+
7+
const responseForRevision = (revision: string): Response =>
8+
Response.json({ ok: true, revision }, {
9+
headers: { "content-type": "application/json" },
10+
status: 200
11+
})
12+
13+
const makeHttpUrl = (host: string, port = "3334"): string => ["ht", "tp://", host, ":", port].join("")
14+
15+
const fetchUrl = (input: Parameters<typeof globalThis.fetch>[0]): string => {
16+
if (typeof input === "string") {
17+
return input
18+
}
19+
return input instanceof URL ? input.toString() : input.url
20+
}
21+
22+
const fetchResponse = (response: Response): ReturnType<typeof globalThis.fetch> =>
23+
Effect.runPromise(Effect.succeed(response))
24+
25+
const withFetchMock = <A, E, R>(
26+
fetchImpl: typeof globalThis.fetch,
27+
effect: Effect.Effect<A, E, R>
28+
): Effect.Effect<A, E, R> =>
29+
Effect.acquireUseRelease(
30+
Effect.sync(() => {
31+
const previous = globalThis.fetch
32+
globalThis.fetch = fetchImpl
33+
return previous
34+
}),
35+
() => effect,
36+
(previous) =>
37+
Effect.sync(() => {
38+
globalThis.fetch = previous
39+
})
40+
)
41+
42+
describe("controller health", () => {
43+
it.effect("skips reachable controllers whose revision does not match the local revision", () =>
44+
Effect.gen(function*(_) {
45+
const oldControllerUrl = makeHttpUrl("old-controller")
46+
const currentControllerUrl = makeHttpUrl("current-controller")
47+
const fetchMock = vi.fn<typeof globalThis.fetch>((input) => {
48+
const url = fetchUrl(input)
49+
return fetchResponse(
50+
url.startsWith(oldControllerUrl)
51+
? responseForRevision("old-revision")
52+
: responseForRevision("local-revision")
53+
)
54+
})
55+
56+
const selected = yield* _(
57+
withFetchMock(
58+
fetchMock,
59+
findReachableApiBaseUrlMatchingRevision(
60+
[oldControllerUrl, currentControllerUrl],
61+
"local-revision"
62+
)
63+
)
64+
)
65+
66+
expect(selected).toBe(currentControllerUrl)
67+
expect(fetchMock).toHaveBeenCalledTimes(2)
68+
}))
69+
70+
it.effect("reports reachable revision mismatches when no candidate matches", () =>
71+
Effect.gen(function*(_) {
72+
const oldControllerUrl = makeHttpUrl("old-controller")
73+
const fetchMock = vi.fn<typeof globalThis.fetch>(() => fetchResponse(responseForRevision("old-revision")))
74+
75+
const error = yield* _(
76+
withFetchMock(
77+
fetchMock,
78+
findReachableApiBaseUrlMatchingRevision([oldControllerUrl], "local-revision")
79+
).pipe(Effect.flip)
80+
)
81+
82+
expect(error.message).toContain("local-revision")
83+
expect(error.message).toContain("old-controller")
84+
expect(error.message).toContain("old-revision")
85+
}))
86+
})

0 commit comments

Comments
 (0)