Skip to content

Commit 9d44709

Browse files
committed
PR Review
1 parent 014e53e commit 9d44709

8 files changed

Lines changed: 520 additions & 70 deletions

File tree

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ The URL must be of the form `https://api.<domain>`. The CLI derives other servic
6969
| API | `https://api.<domain>` (the value of `RUNLOOP_BASE_URL`) |
7070
| Platform | `https://platform.<domain>` |
7171
| SSH | `ssh.<domain>:443` |
72-
| PTY | `https://pty.<domain>` |
73-
| Tunnels | `tunnel.<domain>` |
72+
| Tunnels | `tunnel.<domain>` (PTY sessions reach the devbox over a tunnel created via the API) |
7473

7574
## Usage
7675

src/commands/devbox/pty.ts

Lines changed: 39 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -116,41 +116,21 @@ async function execCommand(
116116
authToken,
117117
});
118118
const ws = await openPtyWebSocket(wsUrl, authToken);
119-
await refreshPtySessionAfterAttach(
120-
ws,
121-
baseUrl,
122-
sessionName,
123-
80,
124-
24,
125-
authToken,
126-
);
127-
ws.send(command + "\n");
128-
129-
return new Promise<void>((resolve, reject) => {
130-
const releaseOnce = createPtySessionReleaser(
131-
baseUrl,
132-
sessionName,
133-
authToken,
134-
);
135-
const disposeInterruptSignals = registerPtyInterruptHandlers(
136-
ws,
137-
releaseOnce,
138-
);
139-
140-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
141-
if (PTY_EXEC_TIMEOUT_MS > 0) {
142-
timeoutId = setTimeout(() => {
143-
releaseOnce();
144-
ws.close();
145-
}, PTY_EXEC_TIMEOUT_MS);
146-
}
147119

120+
const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken);
121+
const disposeInterruptSignals = registerPtyInterruptHandlers(ws, releaseOnce);
122+
123+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
124+
125+
const completion = new Promise<void>((resolve, reject) => {
148126
const finish = () => {
149127
if (timeoutId !== undefined) clearTimeout(timeoutId);
150128
releaseOnce();
151129
disposeInterruptSignals();
152130
};
153131

132+
// Attach listeners synchronously *before* any await — otherwise messages
133+
// emitted between ws open and listener registration are dropped.
154134
ws.on("message", (data: WebSocket.RawData) => {
155135
writePtyStreamToStdout(data);
156136
});
@@ -164,7 +144,29 @@ async function execCommand(
164144
finish();
165145
reject(err);
166146
});
147+
148+
if (PTY_EXEC_TIMEOUT_MS > 0) {
149+
timeoutId = setTimeout(() => {
150+
releaseOnce();
151+
ws.close();
152+
}, PTY_EXEC_TIMEOUT_MS);
153+
}
167154
});
155+
156+
await refreshPtySessionAfterAttach(
157+
ws,
158+
baseUrl,
159+
sessionName,
160+
80,
161+
24,
162+
authToken,
163+
);
164+
// Send the command followed by `exit` so the shell terminates and the
165+
// server closes the WebSocket. Without this, the session would stay open
166+
// after the command finished and the CLI would hang.
167+
ws.send(command + "\nexit\n");
168+
169+
return completion;
168170
}
169171

170172
async function interactiveSession(
@@ -181,23 +183,26 @@ async function interactiveSession(
181183
authToken,
182184
});
183185
const ws = await openPtyWebSocket(wsUrl, authToken);
184-
await refreshPtySessionAfterAttach(
186+
187+
// Attach IO listeners before the refresh round-trip so server output
188+
// emitted during the ptyControl HTTP call is not dropped.
189+
const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken);
190+
const { dispose, done } = startPtyIoSession(
185191
ws,
186192
baseUrl,
187193
sessionName,
188-
cols,
189-
rows,
190194
authToken,
191195
);
196+
const disposeSignals = registerPtyInterruptHandlers(ws, releaseOnce);
192197

193-
const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken);
194-
const { dispose, done } = startPtyIoSession(
198+
await refreshPtySessionAfterAttach(
195199
ws,
196200
baseUrl,
197201
sessionName,
202+
cols,
203+
rows,
198204
authToken,
199205
);
200-
const disposeSignals = registerPtyInterruptHandlers(ws, releaseOnce);
201206

202207
try {
203208
await done;

src/components/InteractivePty.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
resolvePtyWebSocketUrl,
66
refreshPtySessionAfterAttach,
77
startPtyIoSession,
8+
PTY_NORMAL_CLOSE_CODE,
89
} from "../lib/pty-client.js";
910
import { openPtyWebSocket } from "../lib/pty-ws.js";
1011
import { clearScreen } from "../utils/screen.js";
@@ -92,15 +93,8 @@ export const InteractivePty: React.FC<InteractivePtyProps> = ({
9293

9394
wsRef.current = ws;
9495

95-
await refreshPtySessionAfterAttach(
96-
ws,
97-
baseUrl,
98-
sessionName,
99-
cols,
100-
rows,
101-
authToken,
102-
);
103-
96+
// Attach IO listeners before the refresh round-trip so server output
97+
// emitted during the ptyControl HTTP call is not dropped.
10498
const releaseServerSession = createPtySessionReleaser(
10599
baseUrl,
106100
sessionName,
@@ -124,13 +118,22 @@ export const InteractivePty: React.FC<InteractivePtyProps> = ({
124118
return;
125119
}
126120

121+
await refreshPtySessionAfterAttach(
122+
ws,
123+
baseUrl,
124+
sessionName,
125+
cols,
126+
rows,
127+
authToken,
128+
);
129+
127130
done
128131
.then((code) => {
129132
wsRef.current = null;
130133
ioCleanup();
131134
restoreTerminal();
132135
hasStartedRef.current = false;
133-
onExitRef.current?.(code === 4000 ? 0 : code);
136+
onExitRef.current?.(code === PTY_NORMAL_CLOSE_CODE ? 0 : code);
134137
})
135138
.catch((err: Error) => {
136139
wsRef.current = null;

src/lib/pty-client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,13 @@ export function createPtySessionReleaser(
240240
/** Same numeric value as `WebSocket.OPEN` from the `ws` package. */
241241
const WS_READY_STATE_OPEN = 1;
242242

243+
/**
244+
* Application-defined WebSocket close code (range 4000-4999 is reserved for
245+
* applications) the PTY server uses to signal a clean session end — e.g. the
246+
* user typed `exit` and the shell terminated normally. Treated as exit 0.
247+
*/
248+
export const PTY_NORMAL_CLOSE_CODE = 4000;
249+
243250
/**
244251
* After the attach WebSocket is open: re-send terminal size (refreshes session geometry)
245252
* and send CR so the shell redraws the prompt (avoids a blank display until the user hits Enter).

src/lib/pty-ws.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ const PTY_WS_MAX_ATTEMPTS = Math.max(
66
parseInt(process.env.RUNLOOP_PTY_WS_RETRIES || "3", 10) || 3,
77
);
88

9+
/**
10+
* Per-attempt connect timeout. Kept short so the worst-case wall-clock spent
11+
* stacking ptyConnect (up to 3 × 10s back-off) and WS attach retries stays
12+
* bounded; override via env if a slow tunnel ever needs it.
13+
*/
14+
const PTY_WS_CONNECT_TIMEOUT_MS = (() => {
15+
const raw = parseInt(
16+
process.env.RUNLOOP_PTY_WS_CONNECT_TIMEOUT_MS || "15000",
17+
10,
18+
);
19+
return Number.isFinite(raw) && raw > 0 ? raw : 15_000;
20+
})();
21+
22+
/**
23+
* Tunnel-edge HTTP statuses worth retrying on WebSocket upgrade. 502/503 are
24+
* emitted by the mux while the upstream Rage REST listener is still warming up.
25+
*/
26+
const RETRYABLE_UPGRADE_STATUS = /HTTP\s+(502|503)\b/;
27+
928
function delay(ms: number): Promise<void> {
1029
return new Promise((r) => setTimeout(r, ms));
1130
}
@@ -23,7 +42,7 @@ function connectWebSocketOnce(
2342
settled = true;
2443
ws.terminate();
2544
reject(new Error("WebSocket connection timed out"));
26-
}, 45_000);
45+
}, PTY_WS_CONNECT_TIMEOUT_MS);
2746

2847
function finish(ok: boolean, result: WebSocket | Error) {
2948
if (settled) return;
@@ -69,11 +88,7 @@ export async function openPtyWebSocket(
6988
return await connectWebSocketOnce(wsUrl, protocols);
7089
} catch (err) {
7190
lastErr = err instanceof Error ? err : new Error(String(err));
72-
const msg = lastErr.message;
73-
const retryable =
74-
/HTTP\s+(502|503)\b/.test(msg) ||
75-
msg.includes("502") ||
76-
msg.includes("503");
91+
const retryable = RETRYABLE_UPGRADE_STATUS.test(lastErr.message);
7792

7893
if (!retryable || attempt === PTY_WS_MAX_ATTEMPTS) {
7994
throw lastErr;

src/screens/PtySessionScreen.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,25 @@ export function PtySessionScreen() {
1717
const sessionName = params.ptySessionName || params.devboxId;
1818
const authToken = params.ptyAuthToken;
1919
const devboxName = params.devboxName || params.devboxId || "devbox";
20-
const returnScreen = (params.returnScreen as ScreenName) || "devbox-list";
21-
const returnParams = (params.returnParams as RouteParams) || {};
20+
const returnScreen: ScreenName =
21+
(params.returnScreen as ScreenName) || "devbox-list";
22+
23+
// Stabilize returnParams across renders — the inline `|| {}` would otherwise
24+
// produce a fresh object every render and re-fire any effect depending on it.
25+
const returnParamsRaw = params.returnParams as RouteParams | undefined;
26+
const returnParams = React.useMemo(
27+
() => returnParamsRaw ?? {},
28+
[returnParamsRaw],
29+
);
30+
31+
const goBack = React.useCallback(() => {
32+
replace(returnScreen, returnParams);
33+
}, [replace, returnScreen, returnParams]);
2234

2335
const configOk = !!(baseUrl && sessionName);
2436
React.useEffect(() => {
25-
if (configOk) return;
26-
const id = setTimeout(() => {
27-
replace(returnScreen, returnParams || {});
28-
}, 100);
29-
return () => clearTimeout(id);
30-
}, [configOk, replace, returnScreen, returnParams]);
37+
if (!configOk) goBack();
38+
}, [configOk, goBack]);
3139

3240
if (!baseUrl || !sessionName) {
3341
return (
@@ -57,16 +65,8 @@ export function PtySessionScreen() {
5765
baseUrl={baseUrl}
5866
sessionName={sessionName}
5967
authToken={authToken}
60-
onExit={(_code) => {
61-
setTimeout(() => {
62-
replace(returnScreen, returnParams || {});
63-
}, 100);
64-
}}
65-
onError={(_error) => {
66-
setTimeout(() => {
67-
replace(returnScreen, returnParams || {});
68-
}, 100);
69-
}}
68+
onExit={goBack}
69+
onError={goBack}
7070
/>
7171
</>
7272
);

0 commit comments

Comments
 (0)