Skip to content

Commit 4e44af5

Browse files
committed
fix(browser): restart web stack on rerun
1 parent 35b6c20 commit 4e44af5

6 files changed

Lines changed: 329 additions & 12 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ bun run web:dev
8989

9090
Открой `http://127.0.0.1:4174/`.
9191

92+
Одна команда для controller + web frontend:
93+
94+
```bash
95+
bun run docker-git -- browser
96+
```
97+
98+
Если controller или web frontend уже запущены, команда спросит про restart. В non-interactive запуске restart выполняется автоматически.
99+
92100
Preview собранного frontend:
93101

94102
```bash

packages/app/src/docker-git/browser-frontend.ts

Lines changed: 176 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
22
import type { PlatformError } from "@effect/platform/Error"
3-
import { Effect } from "effect"
3+
import { Effect, pipe } from "effect"
44

5-
import { resolveApiBaseUrl } from "./controller.js"
6-
import { runCommandExitCodeStreaming } from "./frontend-lib/shell/command-runner.js"
5+
import { controllerExists } from "./controller-docker.js"
6+
import { type ControllerRuntime, ensureControllerReady, resolveApiBaseUrl, restartController } from "./controller.js"
7+
import {
8+
runCommandCapture,
9+
runCommandExitCode,
10+
runCommandExitCodeStreaming
11+
} from "./frontend-lib/shell/command-runner.js"
712
import type { ControllerBootstrapError } from "./host-errors.js"
813

914
const browserFrontendError = (message: string): ControllerBootstrapError => ({
@@ -25,6 +30,11 @@ const webHost = (): string => process.env["DOCKER_GIT_WEB_HOST"]?.trim() || "127
2530

2631
const webPort = (): string => process.env["DOCKER_GIT_WEB_PORT"]?.trim() || "4174"
2732

33+
type BrowserFrontendRuntimeState = {
34+
readonly controllerRunning: boolean
35+
readonly webPids: ReadonlyArray<string>
36+
}
37+
2838
const browserEnv = (apiBaseUrl: string): Readonly<Record<string, string>> => ({
2939
...copyProcessEnv(),
3040
DOCKER_GIT_API_URL: apiBaseUrl,
@@ -43,6 +53,146 @@ const runStreaming = (
4353
env
4454
})
4555

56+
const parsePids = (output: string): ReadonlyArray<string> =>
57+
output
58+
.split(/\s+/u)
59+
.map((pid) => pid.trim())
60+
.filter((pid) => /^\d+$/u.test(pid))
61+
62+
const findWebServerPids = (): Effect.Effect<ReadonlyArray<string>, never, CommandExecutor.CommandExecutor> => {
63+
const script = [
64+
"port=\"$1\"",
65+
"if command -v lsof >/dev/null 2>&1; then",
66+
" lsof -nP -tiTCP:\"$port\" -sTCP:LISTEN 2>/dev/null || true",
67+
" exit 0",
68+
"fi",
69+
"if command -v fuser >/dev/null 2>&1; then",
70+
String.raw` fuser "$port/tcp" 2>/dev/null | tr ' ' '\n' || true`,
71+
"fi"
72+
].join("\n")
73+
74+
return runCommandCapture(
75+
{
76+
cwd: process.cwd(),
77+
command: "sh",
78+
args: ["-c", script, "sh", webPort()]
79+
},
80+
[0],
81+
() => browserFrontendError("Failed to inspect docker-git browser frontend port.")
82+
).pipe(
83+
Effect.map((output) => parsePids(output)),
84+
Effect.orElseSucceed((): ReadonlyArray<string> => [])
85+
)
86+
}
87+
88+
const stopWebServerPids = (
89+
pids: ReadonlyArray<string>
90+
): Effect.Effect<void, ControllerBootstrapError | PlatformError, CommandExecutor.CommandExecutor> => {
91+
if (pids.length === 0) {
92+
return Effect.void
93+
}
94+
95+
const script = [
96+
"kill \"$@\" 2>/dev/null || true",
97+
"sleep 1",
98+
"kill -9 \"$@\" 2>/dev/null || true"
99+
].join("\n")
100+
101+
return runCommandExitCode({
102+
cwd: process.cwd(),
103+
command: "sh",
104+
args: ["-c", script, "sh", ...pids]
105+
}).pipe(
106+
Effect.flatMap((exitCode) =>
107+
exitCode === 0
108+
? Effect.void
109+
: Effect.fail(browserFrontendError(`Failed to stop browser frontend pids: ${pids.join(", ")}`))
110+
)
111+
)
112+
}
113+
114+
const readBrowserFrontendRuntimeState = (): Effect.Effect<
115+
BrowserFrontendRuntimeState,
116+
never,
117+
ControllerRuntime
118+
> =>
119+
Effect.all({
120+
controllerRunning: controllerExists().pipe(Effect.orElseSucceed(() => false)),
121+
webPids: findWebServerPids()
122+
})
123+
124+
const renderRunningSummary = (state: BrowserFrontendRuntimeState): string =>
125+
[
126+
state.controllerRunning ? "API controller is already running" : "",
127+
state.webPids.length > 0 ? `browser frontend is listening on port ${webPort()}` : ""
128+
].filter((line) => line.length > 0).join("; ")
129+
130+
const hasRunningBrowserStack = (state: BrowserFrontendRuntimeState): boolean =>
131+
state.controllerRunning || state.webPids.length > 0
132+
133+
const normalizePromptAnswer = (answer: string): boolean => {
134+
const normalized = answer.trim().toLowerCase()
135+
return normalized.length === 0 || normalized === "y" || normalized === "yes" || normalized === "д" ||
136+
normalized === "да"
137+
}
138+
139+
const promptRestart = (state: BrowserFrontendRuntimeState): Effect.Effect<boolean> => {
140+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
141+
return Effect.succeed(true)
142+
}
143+
144+
return Effect.async((resume) => {
145+
const onData = (chunk: Buffer) => {
146+
process.stdin.off("data", onData)
147+
resume(Effect.succeed(normalizePromptAnswer(chunk.toString("utf8"))))
148+
}
149+
150+
process.stdout.write(`${renderRunningSummary(state)}. Restart API and web frontend? [Y/n] `)
151+
process.stdin.resume()
152+
process.stdin.once("data", onData)
153+
154+
return Effect.sync(() => {
155+
process.stdin.off("data", onData)
156+
})
157+
})
158+
}
159+
160+
const shouldRestartBrowserStack = (
161+
state: BrowserFrontendRuntimeState
162+
): Effect.Effect<boolean> => hasRunningBrowserStack(state) ? promptRestart(state) : Effect.succeed(false)
163+
164+
const stopCurrentWebServer = (): Effect.Effect<
165+
void,
166+
ControllerBootstrapError | PlatformError,
167+
CommandExecutor.CommandExecutor
168+
> =>
169+
pipe(
170+
findWebServerPids(),
171+
Effect.tap((pids) =>
172+
pids.length === 0 ? Effect.void : Effect.log(`Stopping existing browser frontend pids: ${pids.join(", ")}`)
173+
),
174+
Effect.flatMap((pids) => stopWebServerPids(pids))
175+
)
176+
177+
const prepareBrowserStack = (): Effect.Effect<
178+
boolean,
179+
ControllerBootstrapError | PlatformError,
180+
ControllerRuntime
181+
> =>
182+
Effect.gen(function*(_) {
183+
const runtimeState = yield* _(readBrowserFrontendRuntimeState())
184+
const restart = yield* _(shouldRestartBrowserStack(runtimeState))
185+
if (!restart) {
186+
yield* _(ensureControllerReady())
187+
return runtimeState.webPids.length === 0
188+
}
189+
190+
yield* _(Effect.log("Restarting docker-git API controller."))
191+
yield* _(restartController())
192+
yield* _(stopCurrentWebServer())
193+
return true
194+
})
195+
46196
const ensureSuccess = (
47197
exitCode: number,
48198
action: string
@@ -71,3 +221,26 @@ export const runBrowserFrontend: Effect.Effect<
71221
const serveExitCode = yield* _(runStreaming(["run", "--cwd", "packages/app", "serve:web"], env))
72222
yield* _(ensureSuccess(serveExitCode, "Browser frontend server"))
73223
})
224+
225+
// CHANGE: make `docker-git browser` idempotent for local development
226+
// WHY: repeated invocations should deploy current controller code and replace the previous web process
227+
// QUOTE(ТЗ): "если её вызвать заново то перезапустит и web и api"
228+
// REF: user-request-2026-04-21-browser-restart
229+
// SOURCE: n/a
230+
// FORMAT THEOREM: ∀run: existing(api ∨ web) ∧ confirm(run) → restarted(api) ∧ restarted(web)
231+
// PURITY: SHELL
232+
// EFFECT: Effect<void, ControllerBootstrapError | PlatformError, CommandExecutor>
233+
// INVARIANT: a confirmed rerun force-recreates the controller before serving the new frontend
234+
// COMPLEXITY: O(processes + compose)
235+
export const runBrowserFrontendCommand: Effect.Effect<
236+
void,
237+
ControllerBootstrapError | PlatformError,
238+
ControllerRuntime
239+
> = pipe(
240+
prepareBrowserStack(),
241+
Effect.flatMap((shouldStartWeb) =>
242+
shouldStartWeb
243+
? runBrowserFrontend
244+
: Effect.log(`docker-git browser frontend is already running at http://${webHost()}:${webPort()}/`)
245+
)
246+
)

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,3 +290,16 @@ export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrap
290290
}
291291
yield* _(startAndRememberController(bootstrapContext))
292292
})
293+
294+
export const restartController = (): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>
295+
Effect.gen(function*(_) {
296+
yield* _(failIfRemoteDockerWithoutApiUrl())
297+
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
298+
if (explicitApiBaseUrl !== undefined) {
299+
yield* _(ensureControllerReady())
300+
return
301+
}
302+
303+
const bootstrapContext = yield* _(loadControllerBootstrapContext())
304+
yield* _(startAndRememberController({ ...bootstrapContext, forceRecreateController: true }))
305+
})

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
renderProjectSummaryLine,
2626
syncState
2727
} from "./api-client.js"
28-
import { runBrowserFrontend } from "./browser-frontend.js"
28+
import { runBrowserFrontendCommand } from "./browser-frontend.js"
2929
import { readCommand } from "./cli/read-command.js"
3030
import { usageText } from "./cli/usage.js"
3131
import { type ControllerRuntime, ensureControllerReady } from "./controller.js"
@@ -236,7 +236,7 @@ const dispatchOperationalCommand = (
236236
): Effect.Effect<void, CliError, ControllerRuntime> =>
237237
Match.value(command).pipe(
238238
Match.when({ _tag: "Menu" }, () => withControllerReady(runMenu)),
239-
Match.when({ _tag: "Browser" }, () => withControllerReady(runBrowserFrontend)),
239+
Match.when({ _tag: "Browser" }, () => runBrowserFrontendCommand),
240240
Match.when({ _tag: "Create" }, handleCreateCommand),
241241
Match.when({ _tag: "Open" }, handleOpenCommand),
242242
Match.when({ _tag: "Status" }, handleStatusCommand),
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { NodeContext as BrowserFrontendTestNodeContext } from "@effect/platform-node"
2+
import { describe, expect, it } from "@effect/vitest"
3+
import { Effect } from "effect"
4+
import { afterEach, beforeEach, vi } from "vitest"
5+
6+
type CommandSpec = {
7+
readonly args: ReadonlyArray<string>
8+
readonly command: string
9+
readonly cwd: string
10+
readonly env?: Readonly<Record<string, string | undefined>>
11+
}
12+
13+
const ensureControllerReadyMock = vi.hoisted(() => vi.fn<() => Effect.Effect<void>>())
14+
const restartControllerMock = vi.hoisted(() => vi.fn<() => Effect.Effect<void>>())
15+
const controllerExistsMock = vi.hoisted(() => vi.fn<() => Effect.Effect<boolean>>())
16+
const runCommandCaptureMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect<string>>())
17+
const runCommandExitCodeMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect<number>>())
18+
const runCommandExitCodeStreamingMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect<number>>())
19+
20+
vi.mock("../../src/docker-git/controller.js", () => ({
21+
ensureControllerReady: ensureControllerReadyMock,
22+
resolveApiBaseUrl: () => "http://127.0.0.1:3334",
23+
restartController: restartControllerMock
24+
}))
25+
26+
vi.mock("../../src/docker-git/controller-docker.js", () => ({
27+
controllerExists: controllerExistsMock
28+
}))
29+
30+
vi.mock("../../src/docker-git/frontend-lib/shell/command-runner.js", () => ({
31+
runCommandCapture: runCommandCaptureMock,
32+
runCommandExitCode: runCommandExitCodeMock,
33+
runCommandExitCodeStreaming: runCommandExitCodeStreamingMock
34+
}))
35+
36+
const originalStdinTty = process.stdin.isTTY
37+
const originalStdoutTty = process.stdout.isTTY
38+
39+
const makeNonInteractive = (): void => {
40+
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false })
41+
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false })
42+
}
43+
44+
const restoreTty = (): void => {
45+
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalStdinTty })
46+
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalStdoutTty })
47+
}
48+
49+
const runBrowserCommandUnderTest = Effect.gen(function*(_) {
50+
const { runBrowserFrontendCommand } = yield* _(
51+
Effect.promise(() => import("../../src/docker-git/browser-frontend.js"))
52+
)
53+
yield* _(runBrowserFrontendCommand.pipe(Effect.provide(BrowserFrontendTestNodeContext.layer)))
54+
})
55+
56+
describe("browser frontend command", () => {
57+
beforeEach(() => {
58+
vi.resetModules()
59+
makeNonInteractive()
60+
ensureControllerReadyMock.mockReset()
61+
ensureControllerReadyMock.mockImplementation(() => Effect.void)
62+
restartControllerMock.mockReset()
63+
restartControllerMock.mockImplementation(() => Effect.void)
64+
controllerExistsMock.mockReset()
65+
controllerExistsMock.mockImplementation(() => Effect.succeed(false))
66+
runCommandCaptureMock.mockReset()
67+
runCommandCaptureMock.mockImplementation(() => Effect.succeed(""))
68+
runCommandExitCodeMock.mockReset()
69+
runCommandExitCodeMock.mockImplementation(() => Effect.succeed(0))
70+
runCommandExitCodeStreamingMock.mockReset()
71+
runCommandExitCodeStreamingMock.mockImplementation(() => Effect.succeed(0))
72+
})
73+
74+
afterEach(() => {
75+
restoreTty()
76+
delete process.env["DOCKER_GIT_WEB_PORT"]
77+
delete process.env["DOCKER_GIT_WEB_HOST"]
78+
})
79+
80+
it.effect("starts controller and web when nothing is running", () =>
81+
Effect.gen(function*(_) {
82+
yield* _(runBrowserCommandUnderTest)
83+
84+
expect(ensureControllerReadyMock).toHaveBeenCalledTimes(1)
85+
expect(restartControllerMock).not.toHaveBeenCalled()
86+
expect(runCommandExitCodeMock).not.toHaveBeenCalled()
87+
expect(runCommandExitCodeStreamingMock).toHaveBeenCalledTimes(2)
88+
}))
89+
90+
it.effect("restarts controller and replaces the web process when rerun non-interactively", () =>
91+
Effect.gen(function*(_) {
92+
const events: Array<string> = []
93+
controllerExistsMock.mockImplementation(() => Effect.succeed(true))
94+
runCommandCaptureMock.mockImplementation(() => Effect.succeed("123\n"))
95+
restartControllerMock.mockImplementation(() =>
96+
Effect.sync(() => {
97+
events.push("restart-controller")
98+
})
99+
)
100+
runCommandExitCodeMock.mockImplementation((spec) =>
101+
Effect.sync(() => {
102+
events.push(`stop:${spec.args.join(" ")}`)
103+
return 0
104+
})
105+
)
106+
runCommandExitCodeStreamingMock.mockImplementation((spec) =>
107+
Effect.sync(() => {
108+
events.push(`stream:${spec.args.join(" ")}`)
109+
return 0
110+
})
111+
)
112+
113+
yield* _(runBrowserCommandUnderTest)
114+
115+
expect(ensureControllerReadyMock).not.toHaveBeenCalled()
116+
expect(events).toEqual([
117+
"restart-controller",
118+
"stop:-c kill \"$@\" 2>/dev/null || true\nsleep 1\nkill -9 \"$@\" 2>/dev/null || true sh 123",
119+
"stream:run --cwd packages/app build:web",
120+
"stream:run --cwd packages/app serve:web"
121+
])
122+
}))
123+
})

0 commit comments

Comments
 (0)