fix(deploy-progress): decouple deploys from the live progress stream (#1246)

* fix(deploy-progress): decouple deploys from the live progress stream

The deploy progress modal streamed compose output over a WebSocket, but
the deploy itself was coupled to that socket in two ways that could break
or silently abort a deploy:

- The deploy request was gated on the progress socket connecting, so any
  upgrade failure (a reverse proxy blocking WebSocket upgrades, or the
  admin-only stream rejecting a scoped deployer) left the modal stuck on
  "Connecting..." and the deploy never fired.
- The backend terminated the running compose process when that socket
  closed, so minimizing the modal, navigating away, or a network blip
  aborted an in-flight deploy.

Make the progress socket output-only: the deploy is owned by its request
and runs to completion (or the existing command timeout) regardless of
the stream. The modal now degrades to a "Live progress unavailable" state
and still reports success or failure from the request result. Connect
failures, drops, and a connect timeout all release the deploy instead of
blocking it.

Also route progress output per deploy: the frontend sends a correlation
id on both the connectTerminal message and the deploy request header, and
the backend keys progress sockets by that id so concurrent deploys from
different tabs or users no longer cross-stream each other's output.

Cap the in-memory parsed log rows so a very long deploy cannot grow the
modal's state unbounded.

* fix(deploy-progress): generate the deploy session id with a CSPRNG

The per-deploy correlation id keys which WebSocket receives a deploy's
live output, so a guessable id lets one authenticated client register a
victim's id and read its compose output. It was built from Math.random()
plus a timestamp, which is not cryptographically secure.

Generate it with crypto.getRandomValues (128 bits, hex). That is the one
Crypto member available in insecure contexts, so it still works over LAN
HTTP where crypto.randomUUID is unavailable.

* fix(deploy-progress): stop headerless ops bleeding into a keyed progress modal

Address review findings on the progress-stream routing:

- Only an id-less connectTerminal registration may become the id-less
  fallback socket. Previously every connectTerminal (including keyed deploy
  modals) set the fallback, so a headerless operation (bulk update, rollback,
  or a legacy client) resolved via getTerminalWs() into another user's keyed
  deploy modal. Keyed sockets are now excluded from the fallback, and a socket
  that adopts a session id is removed from it.
- The connect-timeout fallback now also flags the modal as "Live progress
  unavailable" instead of leaving it on "Connecting..." while the deploy runs.
- Log only a short prefix of the deploy session id in developer diagnostics,
  not the full capability value.
This commit is contained in:
Anso
2026-05-28 20:51:45 -04:00
committed by GitHub
parent b034de58f3
commit 5dea040ec8
19 changed files with 577 additions and 89 deletions
+105 -18
View File
@@ -20,6 +20,18 @@ export interface DeployPanelState {
action: ActionVerb;
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
errorMessage?: string;
/**
* True once the progress socket has failed to connect or dropped. The deploy
* is owned by its HTTP request, so it still runs to completion; this only
* tells the UI that live output is no longer arriving.
*/
progressUnavailable: boolean;
/**
* Per-deploy correlation id sent to the backend on both the `connectTerminal`
* WebSocket message and the deploy POST header, so concurrent deploys never
* cross-stream output. Empty until the first runWithLog call.
*/
deploySessionId: string;
/**
* Monotonic id incremented on every runWithLog call. Lets external
* consumers (e.g. the sidebar footer elapsed-time tracker) detect a new
@@ -37,11 +49,12 @@ interface RunResult {
interface DeployFeedbackContextValue {
runWithLog: (
params: { stackName: string; action: ActionVerb },
run: (deployStarted: Promise<void>) => Promise<RunResult>
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>
) => Promise<RunResult>;
panelState: DeployPanelState;
logRows: ParsedLogRow[];
onTerminalReady: () => void;
onTerminalError: () => void;
onMessage: (text: string) => void;
onPanelClose: () => void;
}
@@ -51,19 +64,45 @@ const DEFAULT_PANEL_STATE: DeployPanelState = {
stackName: '',
action: 'deploy',
status: 'preparing',
progressUnavailable: false,
deploySessionId: '',
sessionId: 0,
};
/**
* Upper bound on the parsed rows kept in memory for one deploy. A verbose deploy
* (many services, many image layers) can emit thousands of lines; past this cap
* the oldest rows are dropped and a single sentinel row marks the truncation, so
* state and the rendered DOM stay bounded. The xterm raw view keeps its own
* 10k-line scrollback independently.
*/
const MAX_LOG_ROWS = 5000;
const TRUNCATION_ROW_ID = 'row-truncated';
/**
* Last-resort fallback: if the progress socket neither opens nor errors within
* this window, release the deploy anyway so a silently stalled connection never
* blocks the deploy itself. Connect failures normally resolve far sooner via
* onTerminalError.
*/
const PROGRESS_CONNECT_TIMEOUT_MS = 8000;
const DeployFeedbackContext = createContext<DeployFeedbackContextValue | undefined>(undefined);
export function DeployFeedbackProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [panelState, setPanelState] = useState<DeployPanelState>(DEFAULT_PANEL_STATE);
const [logRows, setLogRows] = useState<ParsedLogRow[]>([]);
// Holds the resolver for the current session's deployStarted promise.
// Updated at the start of each runWithLog call; not state because
// changing it must not trigger a re-render.
const readyResolverRef = useRef<(() => void) | null>(null);
// Idempotent resolver for the current session's deployStarted gate. Set at the
// start of each runWithLog call; called by onTerminalReady (stream connected),
// onTerminalError (stream failed/dropped), or the connect-timeout fallback.
// Not state: changing it must not trigger a re-render.
const settleStartRef = useRef<(() => void) | null>(null);
// Whether the progress stream has connected for the current session. Lets
// onTerminalError distinguish a connect failure (release the deploy gate) from
// a mid-stream drop (gate already released; just mark output unavailable).
const streamReadyRef = useRef(false);
// Tracks whether a session is still active so a cancelled session
// cannot mutate state for the new session that replaced it.
@@ -76,22 +115,43 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
const [isEnabled] = useDeployFeedbackEnabled();
const onTerminalReady = useCallback(() => {
setPanelState((prev) => ({ ...prev, status: 'streaming' }));
if (readyResolverRef.current !== null) {
readyResolverRef.current();
readyResolverRef.current = null;
}
streamReadyRef.current = true;
setPanelState((prev) => (prev.status === 'preparing' ? { ...prev, status: 'streaming' } : prev));
// Give the connectTerminal handshake a beat to register on the backend
// before the deploy POST fires, so the first lines are not missed.
setTimeout(() => settleStartRef.current?.(), 50);
}, []);
const onTerminalError = useCallback(() => {
// The progress socket failed to connect or dropped mid-stream. The deploy is
// owned by its HTTP request, not this socket, so flag that live output is
// gone and, if the stream never connected, release the gate so the deploy
// still fires.
setPanelState((prev) => (prev.isOpen ? { ...prev, progressUnavailable: true } : prev));
if (!streamReadyRef.current) settleStartRef.current?.();
}, []);
const onMessage = useCallback((text: string) => {
const newRows = parseLogChunk(text, idCounterRef.current);
idCounterRef.current += newRows.length;
setLogRows((prev) => [...prev, ...newRows]);
setLogRows((prev) => {
const combined = prev.length > 0 && prev[0].id === TRUNCATION_ROW_ID
? [...prev.slice(1), ...newRows]
: [...prev, ...newRows];
if (combined.length <= MAX_LOG_ROWS) return combined;
const kept = combined.slice(combined.length - MAX_LOG_ROWS);
return [
{ id: TRUNCATION_ROW_ID, timestamp: kept[0].timestamp, stage: 'LOG', level: 'info',
message: `... earlier output truncated (showing last ${MAX_LOG_ROWS} lines) ...`, raw: '' },
...kept,
];
});
}, []);
const onPanelClose = useCallback(() => {
sessionIdRef.current += 1;
readyResolverRef.current = null;
settleStartRef.current = null;
streamReadyRef.current = false;
setPanelState(DEFAULT_PANEL_STATE);
setLogRows([]);
}, []);
@@ -99,15 +159,26 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
const runWithLog = useCallback(
async (
params: { stackName: string; action: ActionVerb },
run: (deployStarted: Promise<void>) => Promise<RunResult>
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>
): Promise<RunResult> => {
// Unique, unguessable per-deploy id correlating the progress socket with
// the deploy POST so concurrent deploys cannot read each other's output.
// Uses crypto.getRandomValues (the one Crypto member available in insecure
// contexts, so it works over LAN HTTP, unlike crypto.randomUUID). When the
// feature is disabled the id is never registered on the backend, so output
// simply streams nowhere.
const idBytes = new Uint8Array(16);
crypto.getRandomValues(idBytes);
const deploySessionId = Array.from(idBytes, (b) => b.toString(16).padStart(2, '0')).join('');
if (!isEnabled) {
return run(Promise.resolve());
return run(Promise.resolve(), deploySessionId);
}
// Cancel any existing session before starting a new one.
sessionIdRef.current += 1;
const mySession = sessionIdRef.current;
streamReadyRef.current = false;
// idCounterRef is intentionally not reset; keys must remain globally unique across sessions.
setLogRows([]);
@@ -117,18 +188,34 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
stackName: params.stackName,
action: params.action,
status: 'preparing',
progressUnavailable: false,
deploySessionId,
sessionId: mySession,
});
const deployStarted = new Promise<void>((resolve) => {
readyResolverRef.current = () => {
setTimeout(resolve, 50);
let done = false;
const settle = () => {
if (done) return;
done = true;
clearTimeout(timer);
resolve();
};
// settle only runs asynchronously (timer fire or onTerminalReady/Error),
// by which point `timer` is assigned, so the forward reference is safe.
const timer = setTimeout(() => {
// The stream neither connected nor errored within the window. Mark live
// output unavailable (so the modal stops showing "Connecting...") and
// release the deploy so a silent stall never blocks it.
setPanelState((prev) => (prev.isOpen ? { ...prev, progressUnavailable: true } : prev));
settle();
}, PROGRESS_CONNECT_TIMEOUT_MS);
settleStartRef.current = settle;
});
let result: RunResult;
try {
result = await run(deployStarted);
result = await run(deployStarted, deploySessionId);
} catch (err) {
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
result = { ok: false, errorMessage: message };
@@ -149,7 +236,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
return (
<DeployFeedbackContext.Provider
value={{ runWithLog, panelState, logRows, onTerminalReady, onMessage, onPanelClose }}
value={{ runWithLog, panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
>
{children}
</DeployFeedbackContext.Provider>
@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { DeployFeedbackProvider, useDeployFeedback } from '../DeployFeedbackContext';
import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled';
function wrapper({ children }: { children: ReactNode }) {
return <DeployFeedbackProvider>{children}</DeployFeedbackProvider>;
}
describe('DeployFeedbackContext', () => {
beforeEach(() => {
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'true');
});
afterEach(() => {
localStorage.clear();
});
it('releases the deploy when the progress stream fails before connecting', async () => {
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
let deployRan = false;
let outer: Promise<unknown> | undefined;
await act(async () => {
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
await started;
deployRan = true;
return { ok: true };
});
// Let runWithLog install the start gate and let run() reach `await started`.
await Promise.resolve();
});
// The deploy must not have fired yet: it is gated on the progress stream.
expect(deployRan).toBe(false);
// A connect failure (e.g. the admin-only /ws gate rejecting a scoped deployer,
// or a reverse proxy blocking the upgrade) must release the gate, not hang.
await act(async () => {
result.current.onTerminalError();
await outer;
});
expect(deployRan).toBe(true);
expect(result.current.panelState.progressUnavailable).toBe(true);
expect(result.current.panelState.status).toBe('succeeded');
});
it('marks progress unavailable on a mid-stream drop without re-running or blocking the deploy', async () => {
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
let runCount = 0;
let outer: Promise<unknown> | undefined;
await act(async () => {
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
await started;
runCount += 1;
return { ok: true };
});
await Promise.resolve();
});
// Stream connects -> gate releases (after the 50ms buffer) -> deploy runs once.
await act(async () => {
result.current.onTerminalReady();
await outer;
});
expect(runCount).toBe(1);
expect(result.current.panelState.status).toBe('succeeded');
// A late socket drop only flags unavailability; it must not re-settle or re-run.
act(() => {
result.current.onTerminalError();
});
expect(result.current.panelState.progressUnavailable).toBe(true);
expect(runCount).toBe(1);
});
it('releases the deploy via the connect timeout when the stream never signals', async () => {
vi.useFakeTimers();
try {
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
let runCount = 0;
let outer: Promise<unknown> | undefined;
await act(async () => {
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
await started;
runCount += 1;
return { ok: true };
});
await Promise.resolve();
});
expect(runCount).toBe(0);
// Neither ready nor error fires; the 8s fallback must still release the deploy
// and flag live output unavailable so the modal stops showing "Connecting...".
await act(async () => {
await vi.advanceTimersByTimeAsync(8000);
await outer;
});
expect(runCount).toBe(1);
expect(result.current.panelState.progressUnavailable).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('replaces the truncation sentinel instead of stacking it on repeated overflow', () => {
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
act(() => {
result.current.onMessage(Array.from({ length: 6000 }, (_, i) => `a ${i}`).join('\n'));
});
act(() => {
result.current.onMessage('b 0\nb 1');
});
expect(result.current.logRows.length).toBe(5001);
expect(result.current.logRows.filter((r) => r.id === 'row-truncated').length).toBe(1);
expect(result.current.logRows[0].id).toBe('row-truncated');
expect(result.current.logRows[result.current.logRows.length - 1].message).toContain('b 1');
});
it('runs immediately with no panel when the feature is disabled', async () => {
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
let deployRan = false;
await act(async () => {
await result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
await started;
deployRan = true;
return { ok: true };
});
});
expect(deployRan).toBe(true);
expect(result.current.panelState.isOpen).toBe(false);
});
it('caps log rows and marks the truncation point', () => {
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
const chunk = Array.from({ length: 6000 }, (_, i) => `line ${i}`).join('\n');
act(() => {
result.current.onMessage(chunk);
});
expect(result.current.logRows.length).toBe(5001);
expect(result.current.logRows[0].id).toBe('row-truncated');
expect(result.current.logRows[result.current.logRows.length - 1].message).toContain('line 5999');
});
});