Skip to content

Commit 01f788e

Browse files
committed
🤖 refactor: extract RemoteRuntime base class for SSH/Docker runtimes
Extract shared logic from SSHRuntime and DockerRuntime into a new RemoteRuntime abstract base class, reducing code duplication by ~194 LoC. Shared functionality now in base class: - exec() with streaming I/O, timeout/abort handling - readFile(), writeFile(), stat() via exec - normalizePath() for POSIX paths - tempDir() returning /tmp Subclasses implement template methods: - spawnRemoteProcess() - how to spawn ssh/docker exec - getBasePath() - base directory for workspace operations - quoteForRemote() - path quoting strategy - cdCommand() - cd with tilde/quote handling - onExitCode() - optional exit code handling (SSH connection pool) Before: SSHRuntime (1247 LoC) + DockerRuntime (814 LoC) = 2061 LoC After: SSHRuntime (868 LoC) + DockerRuntime (588 LoC) + RemoteRuntime (411 LoC) = 1867 LoC _Generated with mux_
1 parent 4face12 commit 01f788e

File tree

4 files changed

+590
-785
lines changed

4 files changed

+590
-785
lines changed

src/node/runtime/DockerRuntime.ts

Lines changed: 27 additions & 254 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,14 @@
77
* - Uses docker exec for command execution
88
* - Hardcoded paths: srcBaseDir=/src, bgOutputDir=/tmp/mux-bashes
99
* - Managed lifecycle: container created/destroyed with workspace
10+
*
11+
* Extends RemoteRuntime for shared exec/file operations.
1012
*/
1113

1214
import { spawn, exec } from "child_process";
13-
import { Readable, Writable } from "stream";
1415
import * as path from "path";
1516
import type {
16-
Runtime,
1717
ExecOptions,
18-
ExecStream,
19-
FileStat,
2018
WorkspaceCreationParams,
2119
WorkspaceCreationResult,
2220
WorkspaceInitParams,
@@ -26,13 +24,10 @@ import type {
2624
InitLogger,
2725
} from "./Runtime";
2826
import { RuntimeError } from "./Runtime";
29-
import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes";
30-
import { log } from "@/node/services/log";
27+
import { RemoteRuntime, type SpawnResult } from "./RemoteRuntime";
3128
import { checkInitHookExists, getMuxEnv, runInitHookOnRuntime } from "./initHook";
32-
import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env";
3329
import { getProjectName } from "@/node/utils/runtime/helpers";
3430
import { getErrorMessage } from "@/common/utils/errors";
35-
import { DisposableProcess } from "@/node/utils/disposableExec";
3631
import { streamToString, shescape } from "./streamUtils";
3732

3833
/** Hardcoded source directory inside container */
@@ -125,13 +120,15 @@ export function getContainerName(projectPath: string, workspaceName: string): st
125120

126121
/**
127122
* Docker runtime implementation that executes commands inside Docker containers.
123+
* Extends RemoteRuntime for shared exec/file operations.
128124
*/
129-
export class DockerRuntime implements Runtime {
125+
export class DockerRuntime extends RemoteRuntime {
130126
private readonly config: DockerRuntimeConfig;
131127
/** Container name - set during construction (for existing) or createWorkspace (for new) */
132128
private containerName?: string;
133129

134130
constructor(config: DockerRuntimeConfig) {
131+
super();
135132
this.config = config;
136133
// If container name is provided (existing workspace), store it
137134
if (config.containerName) {
@@ -146,19 +143,24 @@ export class DockerRuntime implements Runtime {
146143
return this.config.image;
147144
}
148145

149-
/**
150-
* Execute command inside Docker container with streaming I/O
151-
*/
152-
exec(command: string, options: ExecOptions): Promise<ExecStream> {
153-
const startTime = performance.now();
146+
// ===== RemoteRuntime abstract method implementations =====
154147

155-
// Short-circuit if already aborted
156-
if (options.abortSignal?.aborted) {
157-
throw new RuntimeError("Operation aborted before execution", "exec");
158-
}
148+
protected readonly commandPrefix = "Docker";
149+
150+
protected getBasePath(): string {
151+
return CONTAINER_SRC_DIR;
152+
}
153+
154+
protected quoteForRemote(filePath: string): string {
155+
return shescape.quote(filePath);
156+
}
159157

160-
// Verify container name is available (set in constructor for existing workspaces,
161-
// or set in createWorkspace for new workspaces)
158+
protected cdCommand(cwd: string): string {
159+
return `cd ${shescape.quote(cwd)}`;
160+
}
161+
162+
protected spawnRemoteProcess(fullCommand: string, options: ExecOptions): SpawnResult {
163+
// Verify container name is available
162164
if (!this.containerName) {
163165
throw new RuntimeError(
164166
"Docker runtime not initialized with container name. " +
@@ -167,234 +169,28 @@ export class DockerRuntime implements Runtime {
167169
"exec"
168170
);
169171
}
170-
const containerName = this.containerName;
171-
172-
// Build command parts
173-
const parts: string[] = [];
174-
175-
// Add cd command if cwd is specified
176-
parts.push(`cd ${shescape.quote(options.cwd)}`);
177-
178-
// Add environment variable exports (user env first, then non-interactive overrides)
179-
const envVars = { ...options.env, ...NON_INTERACTIVE_ENV_VARS };
180-
for (const [key, value] of Object.entries(envVars)) {
181-
parts.push(`export ${key}=${shescape.quote(value)}`);
182-
}
183-
184-
// Add the actual command
185-
parts.push(command);
186-
187-
// Join all parts with && to ensure each step succeeds before continuing
188-
let fullCommand = parts.join(" && ");
189-
190-
// Wrap in bash for consistent shell behavior
191-
fullCommand = `bash -c ${shescape.quote(fullCommand)}`;
192-
193-
// Optionally wrap with timeout
194-
if (options.timeout !== undefined) {
195-
const remoteTimeout = Math.ceil(options.timeout) + 1;
196-
fullCommand = `timeout -s KILL ${remoteTimeout} ${fullCommand}`;
197-
}
198172

199173
// Build docker exec args
200174
const dockerArgs: string[] = ["exec", "-i"];
201175

202176
// Add environment variables directly to docker exec
177+
const envVars = { ...options.env };
203178
for (const [key, value] of Object.entries(envVars)) {
204179
dockerArgs.push("-e", `${key}=${value}`);
205180
}
206181

207-
dockerArgs.push(containerName, "bash", "-c", fullCommand);
208-
209-
log.debug(`Docker command: docker ${dockerArgs.join(" ")}`);
182+
dockerArgs.push(this.containerName, "bash", "-c", fullCommand);
210183

211184
// Spawn docker exec command
212-
const dockerProcess = spawn("docker", dockerArgs, {
185+
const process = spawn("docker", dockerArgs, {
213186
stdio: ["pipe", "pipe", "pipe"],
214187
windowsHide: true,
215188
});
216189

217-
// Wrap in DisposableProcess for automatic cleanup
218-
const disposable = new DisposableProcess(dockerProcess);
219-
220-
// Convert Node.js streams to Web Streams
221-
const stdout = Readable.toWeb(dockerProcess.stdout) as unknown as ReadableStream<Uint8Array>;
222-
const stderr = Readable.toWeb(dockerProcess.stderr) as unknown as ReadableStream<Uint8Array>;
223-
const stdin = Writable.toWeb(dockerProcess.stdin) as unknown as WritableStream<Uint8Array>;
224-
225-
// Track if we killed the process due to timeout or abort
226-
let timedOut = false;
227-
let aborted = false;
228-
229-
// Create promises for exit code and duration
230-
const exitCode = new Promise<number>((resolve, reject) => {
231-
dockerProcess.on("close", (code, signal) => {
232-
if (aborted || options.abortSignal?.aborted) {
233-
resolve(EXIT_CODE_ABORTED);
234-
return;
235-
}
236-
if (timedOut) {
237-
resolve(EXIT_CODE_TIMEOUT);
238-
return;
239-
}
240-
resolve(code ?? (signal ? -1 : 0));
241-
});
242-
243-
dockerProcess.on("error", (err) => {
244-
reject(new RuntimeError(`Failed to execute Docker command: ${err.message}`, "exec", err));
245-
});
246-
});
247-
248-
const duration = exitCode.then(() => performance.now() - startTime);
249-
250-
// Handle abort signal
251-
if (options.abortSignal) {
252-
options.abortSignal.addEventListener("abort", () => {
253-
aborted = true;
254-
disposable[Symbol.dispose]();
255-
});
256-
}
257-
258-
// Handle timeout
259-
if (options.timeout !== undefined) {
260-
const timeoutHandle = setTimeout(() => {
261-
timedOut = true;
262-
disposable[Symbol.dispose]();
263-
}, options.timeout * 1000);
264-
265-
void exitCode.finally(() => clearTimeout(timeoutHandle));
266-
}
267-
268-
return Promise.resolve({ stdout, stderr, stdin, exitCode, duration });
190+
return { process };
269191
}
270192

271-
/**
272-
* Read file contents from container as a stream
273-
*/
274-
readFile(filePath: string, abortSignal?: AbortSignal): ReadableStream<Uint8Array> {
275-
return new ReadableStream<Uint8Array>({
276-
start: async (controller: ReadableStreamDefaultController<Uint8Array>) => {
277-
try {
278-
const stream = await this.exec(`cat ${shescape.quote(filePath)}`, {
279-
cwd: CONTAINER_SRC_DIR,
280-
timeout: 300,
281-
abortSignal,
282-
});
283-
284-
const reader = stream.stdout.getReader();
285-
const exitCodePromise = stream.exitCode;
286-
287-
while (true) {
288-
const { done, value } = await reader.read();
289-
if (done) break;
290-
controller.enqueue(value);
291-
}
292-
293-
const code = await exitCodePromise;
294-
if (code !== 0) {
295-
const stderr = await streamToString(stream.stderr);
296-
throw new RuntimeError(`Failed to read file ${filePath}: ${stderr}`, "file_io");
297-
}
298-
299-
controller.close();
300-
} catch (err) {
301-
if (err instanceof RuntimeError) {
302-
controller.error(err);
303-
} else {
304-
controller.error(
305-
new RuntimeError(
306-
`Failed to read file ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
307-
"file_io",
308-
err instanceof Error ? err : undefined
309-
)
310-
);
311-
}
312-
}
313-
},
314-
});
315-
}
316-
317-
/**
318-
* Write file contents to container atomically from a stream
319-
*/
320-
writeFile(filePath: string, abortSignal?: AbortSignal): WritableStream<Uint8Array> {
321-
const tempPath = `${filePath}.tmp.${Date.now()}`;
322-
const writeCommand = `mkdir -p $(dirname ${shescape.quote(filePath)}) && cat > ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} ${shescape.quote(filePath)}`;
323-
324-
let execPromise: Promise<ExecStream> | null = null;
325-
326-
const getExecStream = () => {
327-
execPromise ??= this.exec(writeCommand, {
328-
cwd: CONTAINER_SRC_DIR,
329-
timeout: 300,
330-
abortSignal,
331-
});
332-
return execPromise;
333-
};
334-
335-
return new WritableStream<Uint8Array>({
336-
write: async (chunk: Uint8Array) => {
337-
const stream = await getExecStream();
338-
const writer = stream.stdin.getWriter();
339-
try {
340-
await writer.write(chunk);
341-
} finally {
342-
writer.releaseLock();
343-
}
344-
},
345-
close: async () => {
346-
const stream = await getExecStream();
347-
await stream.stdin.close();
348-
const exitCode = await stream.exitCode;
349-
350-
if (exitCode !== 0) {
351-
const stderr = await streamToString(stream.stderr);
352-
throw new RuntimeError(`Failed to write file ${filePath}: ${stderr}`, "file_io");
353-
}
354-
},
355-
abort: async (reason?: unknown) => {
356-
const stream = await getExecStream();
357-
await stream.stdin.abort();
358-
throw new RuntimeError(`Failed to write file ${filePath}: ${String(reason)}`, "file_io");
359-
},
360-
});
361-
}
362-
363-
/**
364-
* Get file statistics from container
365-
*/
366-
async stat(filePath: string, abortSignal?: AbortSignal): Promise<FileStat> {
367-
const stream = await this.exec(`stat -c '%s %Y %F' ${shescape.quote(filePath)}`, {
368-
cwd: CONTAINER_SRC_DIR,
369-
timeout: 10,
370-
abortSignal,
371-
});
372-
373-
const [stdout, stderr, exitCode] = await Promise.all([
374-
streamToString(stream.stdout),
375-
streamToString(stream.stderr),
376-
stream.exitCode,
377-
]);
378-
379-
if (exitCode !== 0) {
380-
throw new RuntimeError(`Failed to stat ${filePath}: ${stderr}`, "file_io");
381-
}
382-
383-
const parts = stdout.trim().split(" ");
384-
if (parts.length < 3) {
385-
throw new RuntimeError(`Failed to parse stat output for ${filePath}: ${stdout}`, "file_io");
386-
}
387-
388-
const size = parseInt(parts[0], 10);
389-
const mtime = parseInt(parts[1], 10);
390-
const fileType = parts.slice(2).join(" ");
391-
392-
return {
393-
size,
394-
modifiedTime: new Date(mtime * 1000),
395-
isDirectory: fileType === "directory",
396-
};
397-
}
193+
// ===== Runtime interface implementations =====
398194

399195
resolvePath(filePath: string): Promise<string> {
400196
// Inside container, paths are already absolute
@@ -404,25 +200,6 @@ export class DockerRuntime implements Runtime {
404200
);
405201
}
406202

407-
normalizePath(targetPath: string, basePath: string): string {
408-
const target = targetPath.trim();
409-
let base = basePath.trim();
410-
411-
if (base.length > 1 && base.endsWith("/")) {
412-
base = base.slice(0, -1);
413-
}
414-
415-
if (target === ".") {
416-
return base;
417-
}
418-
419-
if (target.startsWith("/")) {
420-
return target;
421-
}
422-
423-
return base.endsWith("/") ? base + target : base + "/" + target;
424-
}
425-
426203
getWorkspacePath(_projectPath: string, _workspaceName: string): string {
427204
// For Docker, workspace path is always /src inside the container
428205
return CONTAINER_SRC_DIR;
@@ -808,8 +585,4 @@ export class DockerRuntime implements Runtime {
808585
error: "Forking Docker workspaces is not yet implemented. Create a new workspace instead.",
809586
});
810587
}
811-
812-
tempDir(): Promise<string> {
813-
return Promise.resolve("/tmp");
814-
}
815588
}

0 commit comments

Comments
 (0)