Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion packages/api/src/services/project-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { PlatformError } from "@effect/platform/Error"
import * as HttpServerError from "@effect/platform/HttpServerError"
import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
import { Effect } from "effect"
import { Duration, Effect, pipe, Schedule } from "effect"
import * as Stream from "effect/Stream"
import type { IncomingMessage, Server as HttpServer } from "node:http"
import { createConnection, type Socket } from "node:net"
Expand Down Expand Up @@ -96,6 +96,7 @@ const cdpHostHeader = "127.0.0.1:9222"
const browserActivityWriteIntervalMs = 30_000
const browserActivityWrites = new Map<string, number>()
const browserWebSocketCounts = new Map<string, number>()
const browserRuntimeReadySchedule = Schedule.addDelay(Schedule.recurs(40), () => Duration.millis(250))

const hopByHopRequestHeaders = new Set([
"connection",
Expand Down Expand Up @@ -248,6 +249,32 @@ const startBrowserContainer = (
)
)

const renderBrowserRuntimeReadyProbeScript = (): string => [
"set -eu",
`for port in ${browserNoVncPort} ${browserVncPort} ${browserCdpPort}; do`,
" timeout 1 bash -lc \"</dev/tcp/127.0.0.1/${port}\"",
"done"
].join("\n")

const waitForBrowserRuntimeReady = (
cwd: string,
projectContainerName: string
) =>
pipe(
dockerCapture(
cwd,
["exec", projectContainerName, "bash", "-lc", renderBrowserRuntimeReadyProbeScript()],
"docker exec browser runtime ready probe"
),
Effect.asVoid,
Effect.retry(browserRuntimeReadySchedule),
Effect.mapError(() =>
new ApiConflictError({
message: `Browser runtime did not become ready for ${projectContainerName}.`
})
)
)

const parseContainerNetworkEntries = (output: string): ReadonlyArray<ContainerNetworkEntry> =>
output
.trim()
Expand Down Expand Up @@ -439,6 +466,7 @@ export const startProjectBrowserSession = (
const project = yield* _(getProjectItemById(projectId))
const containerName = browserContainerName(project.containerName)
yield* _(startBrowserContainer(project.projectDir, project.containerName))
yield* _(waitForBrowserRuntimeReady(project.projectDir, project.containerName))
const state = yield* _(inspectBrowserContainerState(project.projectDir, containerName))
return browserSessionFromState(projectId, containerName, state, externalOrigin)
})
Expand Down
88 changes: 74 additions & 14 deletions packages/app/src/docker-git/controller-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ const parseHealthRevision = (text: string): string | null =>
}
})

const probeHealth = (apiBaseUrl: string): Effect.Effect<HealthProbeResult, ControllerBootstrapError> =>
const probeHealth = (
apiBaseUrl: string
): Effect.Effect<HealthProbeResult, ControllerBootstrapError, HttpClient.HttpClient> =>
Effect.gen(function*(_) {
const client = yield* _(HttpClient.HttpClient)
const response = yield* _(client.get(`${apiBaseUrl}/health`, { headers: { accept: "application/json" } }))
Expand All @@ -52,7 +54,6 @@ const probeHealth = (apiBaseUrl: string): Effect.Effect<HealthProbeResult, Contr
)
)
}).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.mapError((error): ControllerBootstrapError =>
error._tag === "ControllerBootstrapError"
? error
Expand All @@ -64,45 +65,98 @@ const probeHealth = (apiBaseUrl: string): Effect.Effect<HealthProbeResult, Contr
)

const findReachableHealthProbe = (
candidateUrls: ReadonlyArray<string>
): Effect.Effect<HealthProbeResult, ControllerBootstrapError> =>
candidateUrls: ReadonlyArray<string>,
expectedRevision?: string
): Effect.Effect<HealthProbeResult, ControllerBootstrapError, HttpClient.HttpClient> =>
Effect.gen(function*(_) {
if (candidateUrls.length === 0) {
return yield* _(
Effect.fail(controllerBootstrapError("No docker-git controller endpoint candidates were generated."))
)
}

const mismatches: Array<string> = []
for (const candidateUrl of candidateUrls) {
const healthy = yield* _(probeHealth(candidateUrl).pipe(Effect.either))
if (Either.isRight(healthy)) {
if (Either.isLeft(healthy)) {
continue
}
if (matchesExpectedRevision(healthy.right, expectedRevision)) {
return healthy.right
}
mismatches.push(describeRevisionMismatch(healthy.right))
}

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

const findReachableHealthProbeOrNull = (
candidateUrls: ReadonlyArray<string>
): Effect.Effect<HealthProbeResult | null> =>
findReachableHealthProbe(candidateUrls).pipe(
candidateUrls: ReadonlyArray<string>,
expectedRevision?: string
): Effect.Effect<HealthProbeResult | null, never, HttpClient.HttpClient> =>
findReachableHealthProbe(candidateUrls, expectedRevision).pipe(
Effect.match({
onFailure: () => null,
onSuccess: (probe) => probe
})
)

const matchesExpectedRevision = (
probe: HealthProbeResult,
expectedRevision: string | undefined
): boolean => expectedRevision === undefined || probe.revision === expectedRevision

const describeRevisionMismatch = (probe: HealthProbeResult): string =>
`${probe.apiBaseUrl} revision ${probe.revision ?? "unknown"}`

const noMatchingHealthProbeError = (
expectedRevision: string | undefined,
mismatches: ReadonlyArray<string>
): ControllerBootstrapError =>
expectedRevision !== undefined && mismatches.length > 0
? controllerBootstrapError(
`No docker-git controller endpoint with revision ${expectedRevision} responded. ` +
`Reachable mismatched controllers: ${mismatches.join(", ")}.`
)
: controllerBootstrapError("No docker-git controller endpoint responded to /health.")

export const findReachableApiBaseUrlWithHttpClient = (
candidateUrls: ReadonlyArray<string>,
expectedRevision?: string
): Effect.Effect<string, ControllerBootstrapError, HttpClient.HttpClient> =>
findReachableHealthProbe(candidateUrls, expectedRevision).pipe(Effect.map(({ apiBaseUrl }) => apiBaseUrl))

export const findReachableApiBaseUrl = (
candidateUrls: ReadonlyArray<string>
candidateUrls: ReadonlyArray<string>,
expectedRevision?: string
): Effect.Effect<string, ControllerBootstrapError> =>
findReachableHealthProbe(candidateUrls).pipe(Effect.map(({ apiBaseUrl }) => apiBaseUrl))
findReachableApiBaseUrlWithHttpClient(candidateUrls, expectedRevision).pipe(Effect.provide(FetchHttpClient.layer))

export const findReachableDirectHealthProbe = (options: {
// CHANGE: select only controller endpoints that prove the expected source revision.
// WHY: containerized hosts can see stale controllers through host.docker.internal before the current local controller is reachable.
// QUOTE(ТЗ): "проверь сам что Open Browser кнопка работает"
// REF: user-message-2026-05-29-open-browser-e2e
// SOURCE: n/a
// FORMAT THEOREM: selected(endpoint) -> health(endpoint).revision = expectedRevision
// PURITY: SHELL
// EFFECT: FetchHttpClient health probes.
// INVARIANT: mismatched reachable controllers are rejected rather than reused.
// COMPLEXITY: O(n) health probes where n = |candidateUrls|.
export const findReachableApiBaseUrlMatchingRevision = (
candidateUrls: ReadonlyArray<string>,
expectedRevision: string
): Effect.Effect<string, ControllerBootstrapError> => findReachableApiBaseUrl(candidateUrls, expectedRevision)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

type DirectHealthProbeOptions = {
readonly explicitApiBaseUrl: string | undefined
readonly defaultLocalApiBaseUrl: string | undefined
readonly cachedApiBaseUrl: string | undefined
}): Effect.Effect<HealthProbeResult | null> =>
readonly expectedRevision?: string | undefined
}

export const findReachableDirectHealthProbeWithHttpClient = (
options: DirectHealthProbeOptions
): Effect.Effect<HealthProbeResult | null, never, HttpClient.HttpClient> =>
findReachableHealthProbeOrNull(
buildApiBaseUrlCandidates({
explicitApiBaseUrl: options.explicitApiBaseUrl,
Expand All @@ -112,5 +166,11 @@ export const findReachableDirectHealthProbe = (options: {
currentContainerNetworks: {},
controllerNetworks: {},
port: resolveApiPort()
})
}),
options.expectedRevision
)

export const findReachableDirectHealthProbe = (
options: DirectHealthProbeOptions
): Effect.Effect<HealthProbeResult | null> =>
findReachableDirectHealthProbeWithHttpClient(options).pipe(Effect.provide(FetchHttpClient.layer))
44 changes: 44 additions & 0 deletions packages/app/src/docker-git/controller-reachability-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Effect } from "effect"

import * as ControllerDocker from "./controller-docker.js"
import { type DockerNetworkIps, formatNetworkIps } from "./controller-reachability.js"

// CHANGE: document controller reachability diagnostics as a SHELL effect.
// WHY: diagnostics inspect published controller ports and must make their runtime dependency explicit.
// QUOTE(ТЗ): n/a
// REF: PR-360-coderabbit-reachability-diagnostics-contract
// SOURCE: n/a
// FORMAT THEOREM: forall candidates C: diagnostics(C) returns a string containing attempted endpoints and runtime network state.
// PURITY: SHELL
// EFFECT: Effect<string, never, ControllerDocker.ControllerRuntime>
// INVARIANT: every returned diagnostic string includes candidate URLs, published ports, current runtime networks, and controller networks.
// COMPLEXITY: O(n) where n = |candidateUrls|.
/**
* Collects host/controller reachability diagnostics for failed bootstrap probes.
*
* @param candidateUrls - API base URL candidates attempted by the bootstrap path.
* @param currentContainerNetworks - network IPs visible from the current runtime container.
* @param controllerNetworks - network IPs attached to the docker-git controller container.
* @returns Effect.Effect<string, never, ControllerDocker.ControllerRuntime>
*
* @pure false
* @effect Requires ControllerDocker.inspectControllerPublishedPorts through ControllerDocker.ControllerRuntime.
* @invariant The result describes controller runtime endpoints, published ports, and network visibility.
* @complexity O(n) where n is candidateUrls.length.
*/
export const collectReachabilityDiagnostics = (
candidateUrls: ReadonlyArray<string>,
currentContainerNetworks: DockerNetworkIps,
controllerNetworks: DockerNetworkIps
): Effect.Effect<string, never, ControllerDocker.ControllerRuntime> =>
Effect.gen(function*(_) {
const publishedPorts = yield* _(ControllerDocker.inspectControllerPublishedPorts())

return [
"Tried endpoints:",
...candidateUrls.map((candidateUrl) => `- ${candidateUrl}`),
`Published ports: ${publishedPorts.length > 0 ? publishedPorts : "unavailable"}`,
`Current runtime networks: ${formatNetworkIps(currentContainerNetworks)}`,
`Controller networks: ${formatNetworkIps(controllerNetworks)}`
].join("\n")
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
42 changes: 17 additions & 25 deletions packages/app/src/docker-git/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { resolveControllerComposeUpArgs, shouldBuildControllerImage } from "./co
import * as ControllerDocker from "./controller-docker.js"
import { findReachableApiBaseUrl, findReachableDirectHealthProbe } from "./controller-health.js"
import { inspectControllerImageRevision } from "./controller-image-revision.js"
import { collectReachabilityDiagnostics } from "./controller-reachability-diagnostics.js"
import {
buildApiBaseUrlCandidates,
type DockerNetworkIps,
formatNetworkIps,
resolveApiPort,
resolveConfiguredApiBaseUrl,
resolveDefaultLocalApiBaseUrl,
Expand Down Expand Up @@ -42,30 +42,14 @@ const rememberSelectedApiBaseUrl = (value: string): void => {
export const resolveApiBaseUrl = (): string =>
resolveExplicitApiBaseUrl() ?? selectedApiBaseUrl ?? resolveConfiguredApiBaseUrl()

const collectReachabilityDiagnostics = (
candidateUrls: ReadonlyArray<string>,
currentContainerNetworks: DockerNetworkIps,
controllerNetworks: DockerNetworkIps
): Effect.Effect<string, never, ControllerDocker.ControllerRuntime> =>
Effect.gen(function*(_) {
const publishedPorts = yield* _(ControllerDocker.inspectControllerPublishedPorts())

return [
"Tried endpoints:",
...candidateUrls.map((candidateUrl) => `- ${candidateUrl}`),
`Published ports: ${publishedPorts.length > 0 ? publishedPorts : "unavailable"}`,
`Current runtime networks: ${formatNetworkIps(currentContainerNetworks)}`,
`Controller networks: ${formatNetworkIps(controllerNetworks)}`
].join("\n")
})

const waitForReachableApiBaseUrl = (
candidateUrls: ReadonlyArray<string>,
currentContainerNetworks: DockerNetworkIps,
controllerNetworks: DockerNetworkIps
controllerNetworks: DockerNetworkIps,
expectedRevision: string | undefined
): ControllerEffect<string> =>
pipe(
findReachableApiBaseUrl(candidateUrls),
findReachableApiBaseUrl(candidateUrls, expectedRevision),
Effect.retry(
Schedule.addDelay(Schedule.recurs(30), () => Duration.seconds(2))
),
Expand Down Expand Up @@ -118,9 +102,10 @@ const failIfRemoteDockerWithoutApiUrl = (
}

const findReachableApiBaseUrlOrNull = (
candidateUrls: ReadonlyArray<string>
candidateUrls: ReadonlyArray<string>,
expectedRevision: string | undefined
): Effect.Effect<string | null> =>
findReachableApiBaseUrl(candidateUrls).pipe(
findReachableApiBaseUrl(candidateUrls, expectedRevision).pipe(
Effect.match({
onFailure: () => null,
onSuccess: (apiBaseUrl) => apiBaseUrl
Expand Down Expand Up @@ -209,7 +194,8 @@ const reuseReachableControllerIfPossible = (
context.explicitApiBaseUrl,
context.currentContainerNetworks,
context.initialControllerNetworks
)
),
context.explicitApiBaseUrl === undefined ? context.localControllerRevision : undefined
).pipe(
Effect.map((reachableApiBaseUrl) => {
if (reachableApiBaseUrl === null || context.forceRecreateController) {
Expand Down Expand Up @@ -252,7 +238,12 @@ const startAndRememberController = (
controllerNetworks
)
const reachableApiBaseUrl = yield* _(
waitForReachableApiBaseUrl(candidateUrls, context.currentContainerNetworks, controllerNetworks)
waitForReachableApiBaseUrl(
candidateUrls,
context.currentContainerNetworks,
controllerNetworks,
context.explicitApiBaseUrl === undefined ? context.localControllerRevision : undefined
)
)
rememberSelectedApiBaseUrl(reachableApiBaseUrl)
})
Expand Down Expand Up @@ -292,7 +283,8 @@ export const ensureControllerReady = (): ControllerEffect<void> =>
findReachableDirectHealthProbe({
cachedApiBaseUrl: selectedApiBaseUrl,
defaultLocalApiBaseUrl,
explicitApiBaseUrl
explicitApiBaseUrl,
expectedRevision: localControllerRevision
})
)
if (
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/web/open-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const prepareOpenUrl = (): PreparedOpenUrl => {
if (typeof globalThis.open !== "function") {
return blockedPreparedOpenUrl()
}
const openedWindow = globalThis.open("about:blank", "_blank", "noopener")
const openedWindow = globalThis.open("about:blank", "_blank")
if (openedWindow === null) {
return blockedPreparedOpenUrl()
}
Expand Down
Loading
Loading