Skip to content

Commit 9ce2ddf

Browse files
committed
fix(relay): stream deploy logs and invalidate Docker placeholder cache
Docker rebuild left libbitfun_relay_service artifacts from the empty placeholder crate, so relay-admin failed to find db/admin. Also make detached deploy logs line-buffered and pollable so the wizard shows progress while the remote build runs.
1 parent 1335178 commit 9ce2ddf

8 files changed

Lines changed: 154 additions & 20 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Relay Deploy: Live Logs + Docker Cache Invalidation
2+
3+
**Date:** 2026-07-19
4+
**Status:** Approved for implementation
5+
6+
## Problems
7+
8+
1. Deploy wizard log pane stays empty until the remote task finishes or fails.
9+
2. Docker image rebuild fails with `cannot find db/admin in bitfun_relay_service`.
10+
11+
## Root Causes
12+
13+
1. Detached `nohup` redirects stdout to a file (full buffering); poll splitter is fragile on CRLF; frontend `setInterval` delays the first poll and can overlap.
14+
2. Dockerfile dependency-cache cleanup uses `deps/bitfun_relay_service*`, which does not match Cargo artifacts `libbitfun_relay_service-*`; the second build links the empty placeholder crate.
15+
16+
## Design
17+
18+
### Docker
19+
20+
In `src/apps/relay-server/Dockerfile`, invalidate placeholder artifacts with globs `*bitfun_relay_service*`, `*bitfun_relay_server*`, `*relay_admin*`, remove matching `.fingerprint` dirs, and `touch` real sources before the second `cargo build`.
21+
22+
### Live logs
23+
24+
- Launch detached tasks with `stdbuf -oL -eL` when available.
25+
- Set `BUILDKIT_PROGRESS=plain` (and compose `--progress=plain` when supported).
26+
- Split poll stdout on `---\n`, `---\r\n`, or a trimmed `---` line.
27+
- Frontend: immediate first poll + serial `setTimeout` chain; seed a waiting line via i18n.
28+
29+
### Out of scope
30+
31+
- WebSocket log push / PTY
32+
- Configurable deploy git ref (default remains GitHub `main`)
33+
34+
## Verification
35+
36+
- Focused Rust check for `bitfun-services-integrations`
37+
- Web type-check
38+
- Local `docker compose build` for relay-server when feasible

src/apps/relay-server/Dockerfile

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,35 @@ COPY src/apps/relay-server/Cargo.toml ./Cargo.toml
2323
COPY src/crates/services/relay-service/Cargo.toml ../../crates/services/relay-service/Cargo.toml
2424

2525
# Build placeholders first so unchanged dependencies remain cached.
26+
# BuildKit exposes ARG as an env during RUN; cargo rejects CARGO_BUILD_JOBS="".
2627
RUN mkdir -p src/bin ../../crates/services/relay-service/src \
2728
&& printf 'fn main() {}\n' > src/main.rs \
2829
&& printf 'pub use bitfun_relay_service::*;\n' > src/lib.rs \
2930
&& printf 'fn main() {}\n' > src/bin/relay_admin.rs \
3031
&& printf '// placeholder\n' > ../../crates/services/relay-service/src/lib.rs \
32+
&& { [ -n "${CARGO_BUILD_JOBS:-}" ] || unset CARGO_BUILD_JOBS; } \
3133
&& cargo build --release
3234

35+
# Cargo emits libbitfun_relay_service-*.rlib/.rmeta — a bare bitfun_relay_service*
36+
# glob misses those and leaves the empty placeholder crate for the real rebuild.
3337
RUN rm -rf src ../../crates/services/relay-service/src \
3438
target/release/bitfun-relay-server \
3539
target/release/relay-admin \
36-
target/release/deps/bitfun_relay_service* \
37-
target/release/deps/bitfun_relay_server* \
38-
target/release/deps/relay_admin*
40+
target/release/deps/*bitfun_relay_service* \
41+
target/release/deps/*bitfun_relay_server* \
42+
target/release/deps/*relay_admin* \
43+
target/release/.fingerprint/bitfun-relay-service-* \
44+
target/release/.fingerprint/bitfun-relay-server-* \
45+
target/release/.fingerprint/relay-admin-*
3946

4047
COPY src/apps/relay-server/src/ ./src/
4148
COPY src/crates/services/relay-service/src/ ../../crates/services/relay-service/src/
4249

43-
RUN if [ -n "${CARGO_BUILD_JOBS}" ]; then export CARGO_BUILD_JOBS; fi \
50+
RUN touch ../../crates/services/relay-service/src/lib.rs \
51+
src/lib.rs \
52+
src/main.rs \
53+
src/bin/relay_admin.rs \
54+
&& { [ -n "${CARGO_BUILD_JOBS:-}" ] || unset CARGO_BUILD_JOBS; } \
4455
&& cargo build --release \
4556
&& (strip target/release/bitfun-relay-server target/release/relay-admin || true)
4657

src/apps/relay-server/deploy.sh

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,16 @@ else
8787
BUILD_ARGS+=(--build-arg "CARGO_BUILD_JOBS=${RELAY_CARGO_BUILD_JOBS}")
8888
echo " Using CARGO_BUILD_JOBS=${RELAY_CARGO_BUILD_JOBS}"
8989
fi
90+
# Plain progress so nohup/file-redirected deploys still stream build lines.
91+
export BUILDKIT_PROGRESS="${BUILDKIT_PROGRESS:-plain}"
9092
# Do not pass --platform unless the user explicitly set DOCKER_DEFAULT_PLATFORM;
9193
# native builds on amd64/arm64 servers are the supported path.
92-
compose build "${BUILD_ARGS[@]}"
94+
# Compose V2 wants --progress as a global flag; legacy docker-compose has none.
95+
if [ "${#COMPOSE[@]}" -ge 2 ] && [ "${COMPOSE[0]}" = "docker" ] && [ "${COMPOSE[1]}" = "compose" ]; then
96+
docker compose --progress=plain build "${BUILD_ARGS[@]}"
97+
else
98+
compose build "${BUILD_ARGS[@]}"
99+
fi
93100
fi
94101

95102
echo "[2/2] Starting / recreating services..."

src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,12 @@ pub async fn start_task(
197197

198198
// Detach fully: stdio redirected, stdin from /dev/null, so the SSH exec
199199
// channel closes immediately and the task survives disconnects.
200+
// Prefer stdbuf line buffering so docker/cargo output reaches the log file
201+
// while the task is still running (file redirects otherwise fully buffer).
200202
let launch = format!(
201203
"cd {dir} && chmod 700 {stem}.sh && rm -f {stem}.log {stem}.pid \
202-
&& nohup bash {stem}.sh > {stem}.log 2>&1 < /dev/null & echo $! > {stem}.pid"
204+
&& (command -v stdbuf >/dev/null 2>&1 && RUNNER='stdbuf -oL -eL bash' || RUNNER=bash) \
205+
&& nohup $RUNNER ./{stem}.sh > {stem}.log 2>&1 < /dev/null & echo $! > {stem}.pid"
203206
);
204207
exec_ok(manager, connection_id, &launch).await?;
205208
Ok(())
@@ -236,10 +239,7 @@ if [ -f "$LOG" ]; then tail -c +{from} "$LOG"; fi
236239
if code != 0 {
237240
return Err(anyhow!("poll failed (exit {code})"));
238241
}
239-
let (head, output) = match stdout.split_once("---\n") {
240-
Some((h, o)) => (h, o.to_string()),
241-
None => (stdout.as_str(), String::new()),
242-
};
242+
let (head, output) = split_poll_stdout(&stdout);
243243
let get = |key: &str| -> String {
244244
head.lines()
245245
.find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix('=')))
@@ -259,11 +259,32 @@ if [ -f "$LOG" ]; then tail -c +{from} "$LOG"; fi
259259
};
260260
Ok(RelayTaskPoll {
261261
cursor: size,
262-
output,
262+
output: output.to_string(),
263263
status,
264264
})
265265
}
266266

267+
/// Split poll script stdout into the metadata head and incremental log body.
268+
///
269+
/// Accepts LF, CRLF, or a standalone `---` line so SSH/OS line endings cannot
270+
/// drop the entire log payload.
271+
fn split_poll_stdout(stdout: &str) -> (&str, &str) {
272+
if let Some((head, output)) = stdout.split_once("---\r\n") {
273+
return (head, output);
274+
}
275+
if let Some((head, output)) = stdout.split_once("---\n") {
276+
return (head, output);
277+
}
278+
let mut offset = 0usize;
279+
for line in stdout.split_inclusive('\n') {
280+
if line.trim_end_matches(['\r', '\n']) == "---" {
281+
return (&stdout[..offset], &stdout[offset + line.len()..]);
282+
}
283+
offset += line.len();
284+
}
285+
(stdout, "")
286+
}
287+
267288
/// Import a locally-provisioned account into the running relay container.
268289
///
269290
/// `account_json` is the serialized `ImportableAccount` produced client-side
@@ -415,13 +436,42 @@ if [ "${{RELAY_CARGO_BUILD_JOBS:-}}" = "" ] && [ "$MEM_KB" -lt 2097152 ]; then
415436
export RELAY_CARGO_BUILD_JOBS=1
416437
echo ">>> Low memory detected; using RELAY_CARGO_BUILD_JOBS=1"
417438
fi
439+
# Stream BuildKit lines into the detached log file (avoid fancy TTY progress).
440+
export BUILDKIT_PROGRESS=plain
418441
echo ">>> Building and starting the relay container (this can take a while)..."
419442
if [ "$DOCKER" = "sudo docker" ]; then
420-
sudo -E env RELAY_CARGO_BUILD_JOBS="${{RELAY_CARGO_BUILD_JOBS:-}}" bash deploy.sh
443+
sudo -E env RELAY_CARGO_BUILD_JOBS="${{RELAY_CARGO_BUILD_JOBS:-}}" \
444+
BUILDKIT_PROGRESS=plain bash deploy.sh
421445
else
422446
bash deploy.sh
423447
fi
424448
echo {TASK_DONE_MARKER}
425449
"#
426450
)
427451
}
452+
453+
#[cfg(test)]
454+
mod tests {
455+
use super::split_poll_stdout;
456+
457+
#[test]
458+
fn split_poll_stdout_accepts_lf() {
459+
let (head, out) = split_poll_stdout("running=1\nsize=12\nmarker=0\n---\nhello\n");
460+
assert!(head.contains("running=1"));
461+
assert_eq!(out, "hello\n");
462+
}
463+
464+
#[test]
465+
fn split_poll_stdout_accepts_crlf() {
466+
let (head, out) = split_poll_stdout("running=1\r\nsize=12\r\nmarker=0\r\n---\r\nworld\r\n");
467+
assert!(head.contains("running=1"));
468+
assert_eq!(out, "world\r\n");
469+
}
470+
471+
#[test]
472+
fn split_poll_stdout_missing_marker_yields_empty_body() {
473+
let (head, out) = split_poll_stdout("running=0\nsize=0\nmarker=0\n");
474+
assert!(head.contains("running=0"));
475+
assert_eq!(out, "");
476+
}
477+
}

src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ export const RelayDeployWizard: React.FC<RelayDeployWizardProps> = ({
113113
const [activeTask, setActiveTask] = useState<RelayDeployTask | null>(null);
114114
const [taskLog, setTaskLog] = useState('');
115115
const [taskStatus, setTaskStatus] = useState<RelayTaskStatus | null>(null);
116-
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
116+
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
117+
const pollActiveRef = useRef(false);
117118
const cursorRef = useRef(0);
118119
const pollFailuresRef = useRef(0);
119120
const logViewRef = useRef<HTMLPreElement>(null);
@@ -131,8 +132,9 @@ export const RelayDeployWizard: React.FC<RelayDeployWizardProps> = ({
131132
const relayUrl = `http://${serverHost}:${RELAY_PORT}`;
132133

133134
const stopPolling = useCallback(() => {
135+
pollActiveRef.current = false;
134136
if (pollRef.current) {
135-
clearInterval(pollRef.current);
137+
clearTimeout(pollRef.current);
136138
pollRef.current = null;
137139
}
138140
}, []);
@@ -352,17 +354,34 @@ export const RelayDeployWizard: React.FC<RelayDeployWizardProps> = ({
352354
}, [t]);
353355

354356
// ── task polling ─────────────────────────────────────────────────────────
357+
// Serial setTimeout chain (not setInterval): immediate first poll, no overlap
358+
// when an SSH round-trip takes longer than POLL_INTERVAL_MS.
355359
const startTaskPolling = useCallback((task: RelayDeployTask, connId: string) => {
356360
stopPolling();
357361
cursorRef.current = 0;
358362
pollFailuresRef.current = 0;
359-
pollRef.current = setInterval(async () => {
363+
pollActiveRef.current = true;
364+
365+
const scheduleNext = () => {
366+
if (!pollActiveRef.current) return;
367+
pollRef.current = setTimeout(() => {
368+
void tick();
369+
}, POLL_INTERVAL_MS);
370+
};
371+
372+
const tick = async () => {
373+
if (!pollActiveRef.current) return;
360374
try {
361375
const res = await relayDeployApi.poll(connId, task, cursorRef.current);
376+
if (!pollActiveRef.current) return;
362377
cursorRef.current = res.cursor;
363378
pollFailuresRef.current = 0;
364379
if (res.output) {
365-
setTaskLog((prev) => (prev + res.output).slice(-MAX_LOG_CHARS));
380+
setTaskLog((prev) => {
381+
const waiting = t('relayDeploy.waitingRemoteOutput');
382+
const base = prev === waiting ? '' : prev;
383+
return (base + res.output).slice(-MAX_LOG_CHARS);
384+
});
366385
}
367386
if (res.status !== 'running') {
368387
stopPolling();
@@ -375,25 +394,31 @@ export const RelayDeployWizard: React.FC<RelayDeployWizardProps> = ({
375394
window.setTimeout(() => setStep('register'), 800);
376395
}
377396
}
397+
return;
378398
}
379399
} catch (e) {
380400
// Transient SSH blips are expected (the manager auto-reconnects);
381401
// only give up after repeated failures.
402+
if (!pollActiveRef.current) return;
382403
pollFailuresRef.current += 1;
383404
log.warn('task poll failed', e);
384405
if (pollFailuresRef.current >= MAX_POLL_FAILURES) {
385406
stopPolling();
386407
setTaskStatus('failed');
387408
setTaskLog((prev) => `${prev}\n[poll] ${errMsg(e)}`);
409+
return;
388410
}
389411
}
390-
}, POLL_INTERVAL_MS);
391-
}, [runPreflight, stopPolling]);
412+
scheduleNext();
413+
};
414+
415+
void tick();
416+
}, [runPreflight, stopPolling, t]);
392417

393418
const handleInstallDocker = async () => {
394419
if (!connectionId) return;
395420
setError(null);
396-
setTaskLog('');
421+
setTaskLog(t('relayDeploy.waitingRemoteOutput'));
397422
setTaskStatus('running');
398423
setActiveTask('install_docker');
399424
try {
@@ -409,7 +434,7 @@ export const RelayDeployWizard: React.FC<RelayDeployWizardProps> = ({
409434
if (!connectionId) return;
410435
setError(null);
411436
setStep('deploy');
412-
setTaskLog('');
437+
setTaskLog(t('relayDeploy.waitingRemoteOutput'));
413438
setTaskStatus('running');
414439
setActiveTask('deploy');
415440
try {

src/web-ui/src/locales/en-US/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,7 @@
590590
"startDeploy": "Start Deployment",
591591
"deployingTitle": "Deploying relay server…",
592592
"deployingHint": "The first build compiles Rust in Docker and can take several minutes. The deployment continues on the server even if you close this window.",
593+
"waitingRemoteOutput": "Waiting for remote output…",
593594
"deploySucceeded": "Deployment succeeded",
594595
"deployFailed": "Deployment failed",
595596
"retry": "Retry",

src/web-ui/src/locales/zh-CN/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,7 @@
590590
"startDeploy": "开始部署",
591591
"deployingTitle": "正在部署 relay server…",
592592
"deployingHint": "首次构建需要在 Docker 中编译 Rust,可能持续几分钟。即使关闭本窗口,部署也会在服务器上继续进行。",
593+
"waitingRemoteOutput": "正在等待远端输出…",
593594
"deploySucceeded": "部署成功",
594595
"deployFailed": "部署失败",
595596
"retry": "重试",

src/web-ui/src/locales/zh-TW/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,7 @@
590590
"startDeploy": "開始部署",
591591
"deployingTitle": "正在部署 relay server…",
592592
"deployingHint": "首次建構需要在 Docker 中編譯 Rust,可能持續幾分鐘。即使關閉本視窗,部署也會在伺服器上繼續進行。",
593+
"waitingRemoteOutput": "正在等待遠端輸出…",
593594
"deploySucceeded": "部署成功",
594595
"deployFailed": "部署失敗",
595596
"retry": "重試",

0 commit comments

Comments
 (0)