Implement WebSocket support for terminal connections and enhance terminal UI

This commit is contained in:
2025-03-30 00:13:32 +05:30
parent 305650925e
commit 3a75000e12
9 changed files with 590 additions and 225 deletions

View File

@@ -9,6 +9,32 @@ import {
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import Panel from "./Panel"; // Import Panel component import Panel from "./Panel"; // Import Panel component
// Add this function to map file extensions to language identifiers
const getLanguageFromExtension = (extension) => {
const extensionMap = {
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript',
'py': 'python',
'java': 'java',
'c': 'c',
'cpp': 'cpp',
'h': 'c',
'hpp': 'cpp',
'cs': 'csharp',
'go': 'go',
'rb': 'ruby',
'php': 'php',
'html': 'html',
'css': 'css',
'json': 'json',
'md': 'markdown'
};
return extensionMap[extension] || 'text';
};
const EditorArea = ({ const EditorArea = ({
sidebarVisible = true, sidebarVisible = true,
activeView = "explorer", activeView = "explorer",
@@ -62,8 +88,8 @@ const EditorArea = ({
// Add a new state for user input // Add a new state for user input
const [userInput, setUserInput] = useState(""); const [userInput, setUserInput] = useState("");
// Add a new state for waiting for input // Add socket state to track the connection
const [waitingForInput, setWaitingForInput] = useState(false); const [activeSocket, setActiveSocket] = useState(null);
// Focus the input when new file modal opens // Focus the input when new file modal opens
useEffect(() => { useEffect(() => {
@@ -132,6 +158,16 @@ const EditorArea = ({
} }
}, [panelVisible]); }, [panelVisible]);
// Add this useEffect for cleanup
useEffect(() => {
// Cleanup function to close socket when component unmounts
return () => {
if (activeSocket) {
activeSocket.close();
}
};
}, []);
const handleEditorDidMount = (editor) => { const handleEditorDidMount = (editor) => {
editorRef.current = editor; editorRef.current = editor;
}; };
@@ -507,7 +543,7 @@ Happy coding!`;
width: `calc(100% - ${sidebarVisible ? sidebarWidth : 0}px)` width: `calc(100% - ${sidebarVisible ? sidebarWidth : 0}px)`
}; };
// Modify the handleRunCode function to prompt for input first // Update the handleRunCode function
const handleRunCode = async () => { const handleRunCode = async () => {
if (!activeFile) return; if (!activeFile) return;
@@ -517,49 +553,36 @@ Happy coding!`;
setPanelVisible(true); setPanelVisible(true);
} }
// Set state to waiting for input
setWaitingForInput(true);
setActiveRunningFile(activeFile.id);
// Clear previous output and add new command // Clear previous output and add new command
const fileExtension = activeFile.id.split('.').pop().toLowerCase(); const fileExtension = activeFile.id.split('.').pop().toLowerCase();
const language = getLanguageFromExtension(fileExtension); const language = getLanguageFromExtension(fileExtension);
const newOutput = [ const newOutput = [
{ type: 'command', content: `$ run ${activeFile.id}` }, { type: 'command', content: `$ run ${activeFile.id}` },
{ type: 'output', content: 'Waiting for input (press Enter if no input is needed)...' } { type: 'output', content: 'Submitting code...' }
]; ];
setTerminalOutput(newOutput); setTerminalOutput(newOutput);
};
// Add a new function to handle input submission
const handleInputSubmit = async () => {
if (!activeFile || !waitingForInput) return;
// Set running state
setIsRunning(true);
setWaitingForInput(false);
// Add message that we're running with the input
setTerminalOutput(prev => [
...prev,
{ type: 'output', content: userInput ? `Using input: "${userInput}"` : 'Running without input...' }
]);
// Use API URL from environment variable
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080';
try { try {
// Now make the API call with the input that was entered // Close any existing socket
if (activeSocket) {
activeSocket.close();
setActiveSocket(null);
}
// Use API URL from environment variable
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080';
// Submit the code to get an execution ID
const submitResponse = await fetch(`${apiUrl}/submit`, { const submitResponse = await fetch(`${apiUrl}/submit`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
language: getLanguageFromExtension(activeFile.id.split('.').pop().toLowerCase()), language: language,
code: activeFile.content, code: activeFile.content,
input: userInput input: "" // Explicitly passing empty input, no user input handling
}), }),
}); });
@@ -570,87 +593,167 @@ Happy coding!`;
const { id } = await submitResponse.json(); const { id } = await submitResponse.json();
setTerminalOutput(prev => [...prev, { type: 'output', content: `Job submitted with ID: ${id}` }]); setTerminalOutput(prev => [...prev, { type: 'output', content: `Job submitted with ID: ${id}` }]);
// Step 2: Poll for status until completed or failed // Set active running file
let status = 'pending'; setActiveRunningFile(activeFile.id);
while (status !== 'completed' && status !== 'failed') {
// Add a small delay between polls
await new Promise(resolve => setTimeout(resolve, 1000));
const statusResponse = await fetch(`${apiUrl}/status?id=${id}`); // Connect to WebSocket with the execution ID
if (!statusResponse.ok) { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
throw new Error(`Status check failed: ${statusResponse.status}`); const wsBaseUrl = apiUrl.replace(/^https?:\/\//, '');
const wsUrl = `${wsProtocol}//${wsBaseUrl}/ws/terminal?id=${id}`;
setTerminalOutput(prev => [...prev, { type: 'output', content: `Connecting to: ${wsUrl}` }]);
// Create a new WebSocket
const newSocket = new WebSocket(wsUrl);
// Set up event handlers
newSocket.onopen = () => {
console.log("WebSocket connected");
setTerminalOutput(prev => [...prev, { type: 'output', content: 'Connected to execution terminal' }]);
setIsRunning(true);
};
newSocket.onmessage = (event) => {
console.log("WebSocket message received:", event.data);
setTerminalOutput(prev => [...prev, { type: 'output', content: event.data }]);
// Check if this message is likely asking for input (prompt detection)
const isPrompt =
event.data.includes("input") ||
event.data.includes("?") ||
event.data.endsWith(":") ||
event.data.endsWith("> ");
if (isPrompt) {
console.log("Input prompt detected, focusing terminal");
// Force terminal to focus after a prompt is detected
setTimeout(() => {
document.querySelector('.panel-terminal')?.focus();
}, 100);
} }
};
const statusData = await statusResponse.json(); // Add polling for job status
status = statusData.status; let statusCheckInterval;
if (id) {
// Start polling the status endpoint every 2 seconds
statusCheckInterval = setInterval(async () => {
try {
const statusResponse = await fetch(`${apiUrl}/status?id=${id}`);
if (statusResponse.ok) {
const statusData = await statusResponse.json();
// Update terminal with status (for any status type) // If the process is completed or failed, stop polling and update UI
setTerminalOutput(prev => { if (statusData.status === 'completed' || statusData.status === 'failed') {
// Update the last status message or add a new one clearInterval(statusCheckInterval);
const hasStatus = prev.some(line => line.content.includes('Status:')); console.log("Process status:", statusData.status);
if (hasStatus) {
return prev.map(line => // Update the UI to show process is no longer running
line.content.includes('Status:') setIsRunning(false);
? { ...line, content: `Status: ${status}` }
: line // Display the final result if WebSocket didn't capture it
); if (statusData.output && statusData.output.length > 0) {
} else { setTerminalOutput(prev => {
return [...prev, { type: 'output', content: `Status: ${status}` }]; // Check if the output is already in the terminal
const lastOutput = prev[prev.length - 1]?.content || "";
if (!lastOutput.includes(statusData.output)) {
return [...prev, {
type: 'output',
content: `\n[System] Final output:\n${statusData.output}`
}];
}
return prev;
});
}
// Close socket if it's still open
if (newSocket && newSocket.readyState === WebSocket.OPEN) {
newSocket.close();
}
}
}
} catch (error) {
console.error("Status check error:", error);
}
}, 2000);
// Clean up interval when component unmounts or when socket closes
newSocket.addEventListener('close', () => {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
} }
}); });
} }
// Get the result for both completed and failed status newSocket.onclose = (event) => {
const resultResponse = await fetch(`${apiUrl}/result?id=${id}`); console.log("WebSocket closed:", event);
if (!resultResponse.ok) { setIsRunning(false);
throw new Error(`Result fetch failed: ${resultResponse.status}`); setActiveSocket(null);
}
const { output } = await resultResponse.json(); const reason = event.reason ? `: ${event.reason}` : '';
const code = event.code ? ` (code: ${event.code})` : '';
// Format and display output setTerminalOutput(prev => [...prev, {
const outputLines = output.split('\n').map(line => ({ type: 'warning',
type: status === 'failed' ? 'warning' : 'output', content: `Terminal connection closed${reason}${code}`
content: line }]);
}));
setTerminalOutput(prev => [ // Clean up interval
...prev, if (statusCheckInterval) {
{ clearInterval(statusCheckInterval);
type: status === 'failed' ? 'warning' : 'output', }
content: status === 'failed' };
? '------- EXECUTION FAILED -------'
: '------- EXECUTION RESULT -------'
},
...outputLines
]);
if (status === 'failed') { newSocket.onerror = (event) => {
console.error('Code execution failed:', output); console.error("WebSocket error:", event);
} setTerminalOutput(prev => [...prev, {
type: 'warning',
content: `WebSocket error occurred`
}]);
};
// Set the active socket after all handlers are defined
setActiveSocket(newSocket);
} catch (error) { } catch (error) {
console.error("Run code error:", error);
setTerminalOutput(prev => [...prev, { type: 'warning', content: `Error: ${error.message}` }]); setTerminalOutput(prev => [...prev, { type: 'warning', content: `Error: ${error.message}` }]);
} finally {
// Set running state to false
setIsRunning(false); setIsRunning(false);
// Also add cleanup in the error handler
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
}
} }
}; };
// Helper function to convert file extension to language identifier for API // Update handleInputSubmit to ensure the input is sent properly
const getLanguageFromExtension = (extension) => { const handleInputSubmit = () => {
const languageMap = { // Log more detail for debugging
'java': 'java', console.log("Input submit called, active socket:", !!activeSocket, "userInput:", userInput);
'c': 'c',
'cpp': 'cpp',
'py': 'python',
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript'
};
return languageMap[extension] || extension; if (!activeSocket || !userInput.trim()) {
console.warn("Cannot send input: No active socket or empty input");
return;
}
try {
// Add the input to the terminal display
setTerminalOutput(prev => [...prev, { type: 'command', content: `> ${userInput}` }]);
// Send the input via WebSocket with a newline character to ensure it's processed
console.log("Sending input:", userInput);
activeSocket.send(userInput + "\n");
// Clear the input field
setUserInput("");
} catch (error) {
console.error("Error sending input:", error);
setTerminalOutput(prev => [...prev, {
type: 'warning',
content: `Error sending input: ${error.message}`
}]);
}
}; };
// Update this function to also update parent state // Update this function to also update parent state
@@ -834,18 +937,17 @@ Happy coding!`;
document.addEventListener("mouseup", onMouseUp); document.addEventListener("mouseup", onMouseUp);
}} }}
/> />
<Panel <Panel
height={panelHeight} height={panelHeight}
terminalOutput={terminalOutput} terminalOutput={terminalOutput}
isRunning={isRunning} isRunning={isRunning}
waitingForInput={waitingForInput} activeRunningFile={activeRunningFile}
activeRunningFile={activeRunningFile} initialTab="terminal"
initialTab="terminal" onClose={togglePanel}
onClose={togglePanel} userInput={userInput}
userInput={userInput} onUserInputChange={setUserInput}
onUserInputChange={setUserInput} onInputSubmit={handleInputSubmit}
onInputSubmit={handleInputSubmit} />
/>
</> </>
)} )}

View File

@@ -1,5 +1,4 @@
import React from "react"; import React, { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
const Panel = ({ const Panel = ({
@@ -21,9 +20,67 @@ const Panel = ({
setActiveTab(initialTab); setActiveTab(initialTab);
}, [initialTab]); }, [initialTab]);
// Update the renderTerminal function to create an interactive terminal
const renderTerminal = () => { const renderTerminal = () => {
const terminalRef = useRef(null);
const [inputBuffer, setInputBuffer] = useState("");
// Auto-scroll terminal to bottom when content changes
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}
}, [terminalOutput]);
// Set up keyboard event listeners when terminal is focused
useEffect(() => {
const handleKeyDown = (e) => {
if (!isRunning) return;
if (e.key === 'Enter') {
// Send current input buffer through WebSocket
if (inputBuffer.trim() && onInputSubmit) {
e.preventDefault(); // Prevent default Enter behavior
// Important: Set user input and THEN call submit in a sequence
onUserInputChange(inputBuffer);
// Add a small delay before submitting to ensure state update
setTimeout(() => {
onInputSubmit();
// Clear buffer after submission is processed
setInputBuffer("");
}, 10);
}
} else if (e.key === 'Backspace') {
// Handle backspace to remove characters
setInputBuffer(prev => prev.slice(0, -1));
} else if (e.key.length === 1) {
// Add regular characters to input buffer
setInputBuffer(prev => prev + e.key);
}
};
// Add event listener
if (terminalRef.current) {
terminalRef.current.addEventListener('keydown', handleKeyDown);
}
// Clean up
return () => {
if (terminalRef.current) {
terminalRef.current.removeEventListener('keydown', handleKeyDown);
}
};
}, [isRunning, inputBuffer, onInputSubmit, onUserInputChange]);
return ( return (
<div className="panel-terminal"> <div
className="panel-terminal"
ref={terminalRef}
tabIndex={0} // Make div focusable
onClick={() => terminalRef.current?.focus()} // Focus when clicked
>
{terminalOutput.length > 0 ? ( {terminalOutput.length > 0 ? (
// Render output from EditorArea when available // Render output from EditorArea when available
<> <>
@@ -32,36 +89,18 @@ const Panel = ({
{line.type === 'command' ? <span className="terminal-prompt">$</span> : ''} {line.content} {line.type === 'command' ? <span className="terminal-prompt">$</span> : ''} {line.content}
</div> </div>
))} ))}
{waitingForInput && (
<div className="terminal-line"> {/* Show current input with blinking cursor only when connection is active */}
<span className="terminal-prompt">Input:</span> {isRunning && (
<input <div className="terminal-line terminal-input-line">
type="text" <span className="terminal-prompt">$</span> {inputBuffer}
className="terminal-input" <span className="terminal-cursor"></span>
value={userInput}
onChange={(e) => onUserInputChange && onUserInputChange(e.target.value)}
placeholder="Enter input for your program here..."
onKeyDown={(e) => {
if (e.key === 'Enter' && onInputSubmit) {
onInputSubmit();
}
}}
autoFocus
/>
</div> </div>
)} )}
</> </>
) : ( ) : (
// Default terminal content when no output // Default terminal content
<> <>
<div className="terminal-line">
<span className="terminal-prompt">$</span> npm start
</div>
<div className="terminal-line terminal-output">Starting the development server...</div>
<div className="terminal-line terminal-output">Compiled successfully!</div>
<div className="terminal-line terminal-output">You can now view vscode-clone in the browser.</div>
<div className="terminal-line terminal-output">Local: http://localhost:3000</div>
<div className="terminal-line terminal-output">On Your Network: http://192.168.1.5:3000</div>
<div className="terminal-line"> <div className="terminal-line">
<span className="terminal-prompt">$</span> <span className="terminal-prompt">$</span>
</div> </div>

View File

@@ -418,6 +418,16 @@ body {
height: 100%; height: 100%;
} }
.panel-terminal {
padding: 8px;
font-family: monospace;
overflow-y: auto;
height: calc(100% - 36px); /* Adjust based on your header height */
background-color: #1e1e1e;
color: #ddd;
outline: none; /* Remove focus outline */
}
.panel-terminal .terminal-line { .panel-terminal .terminal-line {
white-space: pre-wrap; white-space: pre-wrap;
margin-bottom: 3px; margin-bottom: 3px;
@@ -426,22 +436,20 @@ body {
.terminal-line { .terminal-line {
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.5; line-height: 1.5;
margin-bottom: 2px;
} }
.terminal-prompt { .terminal-prompt {
color: #0f0; color: #0a84ff;
margin-right: 8px; margin-right: 8px;
color: #569cd6;
margin-right: 6px;
} }
.terminal-output { .terminal-output {
color: #888888; color: #ddd;
color: #cccccc;
} }
.terminal-warning { .terminal-warning {
color: #ddb100; color: #ffa500;
} }
.output-line { .output-line {
@@ -463,9 +471,8 @@ body {
} }
@keyframes blink { @keyframes blink {
50% { 0%, 100% { opacity: 1; }
opacity: 0; 50% { opacity: 0; }
}
} }
.panel-empty-message { .panel-empty-message {
@@ -964,9 +971,8 @@ body {
} }
@keyframes blink { @keyframes blink {
50% { 0%, 100% { opacity: 1; }
opacity: 0; 50% { opacity: 0; }
}
} }
/* Make sure the monaco container adjusts when terminal is shown */ /* Make sure the monaco container adjusts when terminal is shown */

View File

@@ -6,6 +6,7 @@ require github.com/stretchr/testify v1.9.0
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/kr/pretty v0.3.0 // indirect github.com/kr/pretty v0.3.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect

View File

@@ -1,6 +1,8 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=

View File

@@ -2,12 +2,15 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log"
"net/http" "net/http"
"sync" "sync"
"time" "time"
"github.com/arnab-afk/monaco/model" "github.com/arnab-afk/monaco/model"
"github.com/arnab-afk/monaco/service" "github.com/arnab-afk/monaco/service"
"github.com/gorilla/websocket"
) )
// Handler manages HTTP requests for code submissions // Handler manages HTTP requests for code submissions
@@ -179,6 +182,63 @@ func (h *Handler) QueueStatsHandler(w http.ResponseWriter, r *http.Request) {
}) })
} }
// ConnectTerminal connects a WebSocket to a running execution
func (h *Handler) ConnectTerminal(conn *websocket.Conn, executionID string) {
// Get submission from storage
h.mu.Lock()
submission, found := h.submissions[executionID]
status := "not found"
if found {
status = submission.Status
}
h.mu.Unlock()
log.Printf("[WS-%s] Terminal connection request, submission status: %s", executionID, status)
if !found {
log.Printf("[WS-%s] Execution not found", executionID)
conn.WriteMessage(websocket.TextMessage, []byte("Execution not found"))
conn.Close()
return
}
// If execution is already completed, send stored output and close
if submission.Status == "completed" || submission.Status == "failed" {
log.Printf("[WS-%s] Execution already %s, sending stored output (length: %d)",
executionID, submission.Status, len(submission.Output))
conn.WriteMessage(websocket.TextMessage, []byte(submission.Output))
conn.Close()
return
}
log.Printf("[WS-%s] Registering connection for real-time updates, current status: %s",
executionID, submission.Status)
// Register this connection with the execution service for real-time updates
h.executionService.RegisterTerminalConnection(executionID, conn)
// Send initial connection confirmation
initialMsg := fmt.Sprintf("[System] Connected to process (ID: %s, Status: %s)\n",
executionID, submission.Status)
conn.WriteMessage(websocket.TextMessage, []byte(initialMsg))
// Handle incoming messages from the terminal (for stdin)
go func() {
for {
_, message, err := conn.ReadMessage()
if err != nil {
log.Printf("[WS-%s] Read error: %v", executionID, err)
h.executionService.UnregisterTerminalConnection(executionID, conn)
break
}
log.Printf("[WS-%s] Received input from client: %s", executionID, string(message))
// Send input to the execution if it's waiting for input
h.executionService.SendInput(executionID, string(message))
}
}()
}
// generateID creates a unique ID for submissions // generateID creates a unique ID for submissions
func (h *Handler) generateID() string { func (h *Handler) generateID() string {
return service.GenerateUUID() return service.GenerateUUID()

View File

@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/arnab-afk/monaco/handler" "github.com/arnab-afk/monaco/handler"
"github.com/gorilla/websocket"
) )
func main() { func main() {
@@ -46,7 +47,40 @@ func main() {
} }
} }
// Register handlers with logging and CORS middleware // Configure WebSocket upgrader
upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// Allow connections from any origin
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// WebSocket handler for terminal connection
http.HandleFunc("/ws/terminal", func(w http.ResponseWriter, r *http.Request) {
// Get execution ID from query parameters
executionID := r.URL.Query().Get("id")
if executionID == "" {
log.Println("[WS] Missing execution ID")
http.Error(w, "Missing execution ID", http.StatusBadRequest)
return
}
// Upgrade HTTP connection to WebSocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[WS] Failed to upgrade connection: %v", err)
return
}
log.Printf("[WS] Terminal connection established for execution ID: %s", executionID)
// Connect this WebSocket to the execution service for real-time streaming
h.ConnectTerminal(conn, executionID)
})
// Register REST API handlers with logging and CORS middleware
http.HandleFunc("/submit", corsMiddleware(loggingMiddleware(h.SubmitHandler))) http.HandleFunc("/submit", corsMiddleware(loggingMiddleware(h.SubmitHandler)))
http.HandleFunc("/status", corsMiddleware(loggingMiddleware(h.StatusHandler))) http.HandleFunc("/status", corsMiddleware(loggingMiddleware(h.StatusHandler)))
http.HandleFunc("/result", corsMiddleware(loggingMiddleware(h.ResultHandler))) http.HandleFunc("/result", corsMiddleware(loggingMiddleware(h.ResultHandler)))

View File

@@ -1,6 +1,8 @@
package service package service
import ( import (
"bytes"
"context"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -8,25 +10,101 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings"
"sync" "sync"
"time" "time"
"github.com/arnab-afk/monaco/model" "github.com/arnab-afk/monaco/model"
"github.com/arnab-afk/monaco/queue" "github.com/arnab-afk/monaco/queue"
"github.com/gorilla/websocket"
) )
// ExecutionService handles code execution for multiple languages // ExecutionService handles code execution for multiple languages
type ExecutionService struct { type ExecutionService struct {
mu sync.Mutex mu sync.Mutex
queue *queue.JobQueue queue *queue.JobQueue
terminalConnections map[string][]*websocket.Conn // Map of executionID to WebSocket connections
execInputChannels map[string]chan string // Map of executionID to input channels
} }
// NewExecutionService creates a new execution service // NewExecutionService creates a new execution service
func NewExecutionService() *ExecutionService { func NewExecutionService() *ExecutionService {
log.Println("Initializing execution service with 3 concurrent workers") log.Println("Initializing execution service with 3 concurrent workers")
return &ExecutionService{ return &ExecutionService{
queue: queue.NewJobQueue(35), // 3 concurrent executions max queue: queue.NewJobQueue(3), // 3 concurrent executions max
terminalConnections: make(map[string][]*websocket.Conn),
execInputChannels: make(map[string]chan string),
}
}
// RegisterTerminalConnection registers a WebSocket connection for an execution
func (s *ExecutionService) RegisterTerminalConnection(executionID string, conn *websocket.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.terminalConnections[executionID]; !exists {
s.terminalConnections[executionID] = make([]*websocket.Conn, 0)
}
s.terminalConnections[executionID] = append(s.terminalConnections[executionID], conn)
log.Printf("[WS-%s] Terminal connection registered, total connections: %d",
executionID, len(s.terminalConnections[executionID]))
}
// UnregisterTerminalConnection removes a WebSocket connection
func (s *ExecutionService) UnregisterTerminalConnection(executionID string, conn *websocket.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
connections, exists := s.terminalConnections[executionID]
if !exists {
return
}
// Remove the specific connection
for i, c := range connections {
if c == conn {
s.terminalConnections[executionID] = append(connections[:i], connections[i+1:]...)
break
}
}
// If no more connections, clean up
if len(s.terminalConnections[executionID]) == 0 {
delete(s.terminalConnections, executionID)
}
log.Printf("[WS-%s] Terminal connection unregistered", executionID)
}
// SendOutputToTerminals sends output to all connected terminals for an execution
func (s *ExecutionService) SendOutputToTerminals(executionID string, output string) {
s.mu.Lock()
connections := s.terminalConnections[executionID]
s.mu.Unlock()
for _, conn := range connections {
if err := conn.WriteMessage(websocket.TextMessage, []byte(output)); err != nil {
log.Printf("[WS-%s] Error sending to terminal: %v", executionID, err)
// Unregister this connection on error
s.UnregisterTerminalConnection(executionID, conn)
}
}
}
// SendInput sends user input to a running process
func (s *ExecutionService) SendInput(executionID string, input string) {
s.mu.Lock()
inputChan, exists := s.execInputChannels[executionID]
s.mu.Unlock()
if exists {
select {
case inputChan <- input:
log.Printf("[WS-%s] Sent input to execution: %s", executionID, input)
default:
log.Printf("[WS-%s] Execution not ready for input", executionID)
}
} else {
log.Printf("[WS-%s] No input channel for execution", executionID)
} }
} }
@@ -110,48 +188,123 @@ func (s *ExecutionService) executeLanguageSpecific(submission *model.CodeSubmiss
func (s *ExecutionService) executeWithInput(cmd *exec.Cmd, input string, timeout time.Duration, submissionID string) ([]byte, error) { func (s *ExecutionService) executeWithInput(cmd *exec.Cmd, input string, timeout time.Duration, submissionID string) ([]byte, error) {
log.Printf("[TIMEOUT-%s] Setting execution timeout: %v", submissionID, timeout) log.Printf("[TIMEOUT-%s] Setting execution timeout: %v", submissionID, timeout)
// Set up input pipe if input is provided // Create pipes for stdin, stdout, and stderr
if input != "" { stdin, stdinErr := cmd.StdinPipe()
stdin, err := cmd.StdinPipe() if stdinErr != nil {
if err != nil { return nil, fmt.Errorf("failed to create stdin pipe: %v", stdinErr)
log.Printf("[ERROR-%s] Failed to create stdin pipe: %v", submissionID, err)
return nil, err
}
// Write input in a goroutine to avoid blocking
go func() {
defer stdin.Close()
io.WriteString(stdin, input)
}()
log.Printf("[INPUT-%s] Providing input to process", submissionID)
} }
done := make(chan struct{}) stdout, stdoutErr := cmd.StdoutPipe()
var output []byte if stdoutErr != nil {
var err error return nil, fmt.Errorf("failed to create stdout pipe: %v", stdoutErr)
}
go func() { stderr, stderrErr := cmd.StderrPipe()
log.Printf("[EXEC-%s] Starting command execution: %v", submissionID, cmd.Args) if stderrErr != nil {
output, err = cmd.CombinedOutput() return nil, fmt.Errorf("failed to create stderr pipe: %v", stderrErr)
close(done) }
// Create an input channel and register it
inputChan := make(chan string, 10)
s.mu.Lock()
s.execInputChannels[submissionID] = inputChan
s.mu.Unlock()
// Clean up the input channel when done
defer func() {
s.mu.Lock()
delete(s.execInputChannels, submissionID)
s.mu.Unlock()
close(inputChan)
}() }()
// Start the command
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start process: %v", err)
}
// Create a buffer to collect all output
var outputBuffer bytes.Buffer
// Handle stdout in a goroutine
go func() {
buffer := make([]byte, 1024)
for {
n, err := stdout.Read(buffer)
if n > 0 {
data := buffer[:n]
outputBuffer.Write(data)
// Send real-time output to connected terminals
s.SendOutputToTerminals(submissionID, string(data))
}
if err != nil {
break
}
}
}()
// Handle stderr in a goroutine
go func() {
buffer := make([]byte, 1024)
for {
n, err := stderr.Read(buffer)
if n > 0 {
data := buffer[:n]
outputBuffer.Write(data)
// Send real-time output to connected terminals
s.SendOutputToTerminals(submissionID, string(data))
}
if err != nil {
break
}
}
}()
// Write initial input if provided
if input != "" {
io.WriteString(stdin, input+"\n")
}
// Process is in a separate context, but it needs to be killed if timeout occurs
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle additional input from WebSocket in a goroutine
go func() {
for {
select {
case additionalInput, ok := <-inputChan:
if !ok {
return
}
log.Printf("[INPUT-%s] Received input from WebSocket: %s", submissionID, additionalInput)
io.WriteString(stdin, additionalInput+"\n")
case <-ctx.Done():
return
}
}
}()
// Wait for the command to complete with timeout
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
// Wait for completion or timeout
select { select {
case <-time.After(timeout): case <-time.After(timeout):
cancel() // Stop the input handler
log.Printf("[TIMEOUT-%s] Execution timed out after %v seconds", submissionID, timeout.Seconds()) log.Printf("[TIMEOUT-%s] Execution timed out after %v seconds", submissionID, timeout.Seconds())
if err := cmd.Process.Kill(); err != nil { if err := cmd.Process.Kill(); err != nil {
log.Printf("[TIMEOUT-%s] Failed to kill process: %v", submissionID, err) log.Printf("[TIMEOUT-%s] Failed to kill process: %v", submissionID, err)
return nil, fmt.Errorf("timeout reached but failed to kill process: %v", err)
} }
return nil, fmt.Errorf("execution timed out after %v seconds", timeout.Seconds()) s.SendOutputToTerminals(submissionID, fmt.Sprintf("\n[System] Process killed after timeout of %v seconds", timeout.Seconds()))
case <-done: return outputBuffer.Bytes(), fmt.Errorf("execution timed out after %v seconds", timeout.Seconds())
if err != nil { case err := <-done:
log.Printf("[EXEC-%s] Command execution failed: %v", submissionID, err) cancel() // Stop the input handler
} else { s.SendOutputToTerminals(submissionID, "\n[System] Process completed")
log.Printf("[EXEC-%s] Command execution completed successfully", submissionID) return outputBuffer.Bytes(), err
}
return output, err
} }
} }
@@ -169,15 +322,9 @@ func (s *ExecutionService) executePython(submission *model.CodeSubmission) {
"python:3.9", "python", "-c", submission.Code) "python:3.9", "python", "-c", submission.Code)
log.Printf("[PYTHON-%s] Executing Python code with timeout: 10s", submission.ID) log.Printf("[PYTHON-%s] Executing Python code with timeout: 10s", submission.ID)
var output []byte
var err error
if submission.Input != "" { // Use the enhanced executeWithInput method for all executions
cmd.Stdin = strings.NewReader(submission.Input) output, err := s.executeWithInput(cmd, submission.Input, 100*time.Second, submission.ID)
output, err = cmd.CombinedOutput()
} else {
output, err = s.executeWithTimeout(cmd, 10*time.Second, submission.ID)
}
elapsed := time.Since(startTime) elapsed := time.Since(startTime)
log.Printf("[PYTHON-%s] Python execution completed in %v", submission.ID, elapsed) log.Printf("[PYTHON-%s] Python execution completed in %v", submission.ID, elapsed)
@@ -255,7 +402,7 @@ func (s *ExecutionService) executeJava(submission *model.CodeSubmission) {
log.Printf("[JAVA-%s] Compilation successful", submission.ID) log.Printf("[JAVA-%s] Compilation successful", submission.ID)
// Now run the compiled class // Now run the compiled class with the enhanced executeWithInput method
runCmd := exec.Command("docker", "run", "--rm", "-i", runCmd := exec.Command("docker", "run", "--rm", "-i",
"--network=none", // No network access "--network=none", // No network access
"--memory=400m", // Memory limit "--memory=400m", // Memory limit
@@ -267,17 +414,8 @@ func (s *ExecutionService) executeJava(submission *model.CodeSubmission) {
"-Xverify:none", "-Xms64m", "-Xmx256m", "-Xverify:none", "-Xms64m", "-Xmx256m",
"-cp", "/code", className) "-cp", "/code", className)
// Add input if provided log.Printf("[JAVA-%s] Executing Java code", submission.ID)
var output []byte output, err := s.executeWithInput(runCmd, submission.Input, 15*time.Second, submission.ID)
if submission.Input != "" {
log.Printf("[JAVA-%s] Executing Java code with input", submission.ID)
runCmd.Stdin = strings.NewReader(submission.Input)
output, err = runCmd.CombinedOutput()
} else {
log.Printf("[JAVA-%s] Executing Java code without input", submission.ID)
output, err = s.executeWithTimeout(runCmd, 15*time.Second, submission.ID)
}
elapsed := time.Since(startTime) elapsed := time.Since(startTime)
log.Printf("[JAVA-%s] Java execution completed in %v", submission.ID, elapsed) log.Printf("[JAVA-%s] Java execution completed in %v", submission.ID, elapsed)
@@ -327,7 +465,7 @@ func (s *ExecutionService) executeC(submission *model.CodeSubmission) {
log.Printf("[C-%s] Compilation successful", submission.ID) log.Printf("[C-%s] Compilation successful", submission.ID)
// Run C executable // Run C executable using executeWithInput to support WebSockets
runCmd := exec.Command("docker", "run", "--rm", "-i", runCmd := exec.Command("docker", "run", "--rm", "-i",
"--network=none", // No network access "--network=none", // No network access
"--memory=100m", // Memory limit "--memory=100m", // Memory limit
@@ -336,17 +474,8 @@ func (s *ExecutionService) executeC(submission *model.CodeSubmission) {
"-v", tempDir+":/code", // Mount code directory "-v", tempDir+":/code", // Mount code directory
"gcc:latest", "/code/solution") "gcc:latest", "/code/solution")
// Add input if provided log.Printf("[C-%s] Executing C code", submission.ID)
var output []byte output, err := s.executeWithInput(runCmd, submission.Input, 30*time.Second, submission.ID)
// Don't redeclare err here - use the existing variable
if submission.Input != "" {
log.Printf("[C-%s] Executing C code with input", submission.ID)
runCmd.Stdin = strings.NewReader(submission.Input)
output, err = runCmd.CombinedOutput() // Use the existing err variable
} else {
log.Printf("[C-%s] Executing C code without input", submission.ID)
output, err = s.executeWithTimeout(runCmd, 10*time.Second, submission.ID) // Use the existing err variable
}
elapsed := time.Since(startTime) elapsed := time.Since(startTime)
log.Printf("[C-%s] C execution completed in %v", submission.ID, elapsed) log.Printf("[C-%s] C execution completed in %v", submission.ID, elapsed)
@@ -396,7 +525,7 @@ func (s *ExecutionService) executeCpp(submission *model.CodeSubmission) {
log.Printf("[CPP-%s] Compilation successful", submission.ID) log.Printf("[CPP-%s] Compilation successful", submission.ID)
// Run C++ executable // Run C++ executable using executeWithInput to support WebSockets
runCmd := exec.Command("docker", "run", "--rm", "-i", runCmd := exec.Command("docker", "run", "--rm", "-i",
"--network=none", // No network access "--network=none", // No network access
"--memory=100m", // Memory limit "--memory=100m", // Memory limit
@@ -405,16 +534,8 @@ func (s *ExecutionService) executeCpp(submission *model.CodeSubmission) {
"-v", tempDir+":/code", // Mount code directory "-v", tempDir+":/code", // Mount code directory
"gcc:latest", "/code/solution") "gcc:latest", "/code/solution")
// Add input if provided log.Printf("[CPP-%s] Executing C++ code", submission.ID)
var output []byte output, err := s.executeWithInput(runCmd, submission.Input, 100*time.Second, submission.ID)
if submission.Input != "" {
log.Printf("[CPP-%s] Executing C++ code with input", submission.ID)
runCmd.Stdin = strings.NewReader(submission.Input)
output, err = runCmd.CombinedOutput()
} else {
log.Printf("[CPP-%s] Executing C++ code without input", submission.ID)
output, err = s.executeWithTimeout(runCmd, 10*time.Second, submission.ID)
}
elapsed := time.Since(startTime) elapsed := time.Since(startTime)
log.Printf("[CPP-%s] C++ execution completed in %v", submission.ID, elapsed) log.Printf("[CPP-%s] C++ execution completed in %v", submission.ID, elapsed)

Binary file not shown.