From b2d43b3db6b1aa17c300b955f04f9f3f24da9dff Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Apr 2026 14:19:56 -0400 Subject: [PATCH 01/17] feat: route variable forcing through IPC command pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the broken GDB expression evaluation path for variable forcing with a cross-platform IPC command pipe. The debug binary now accepts a --cmd-pipe argument to start a command server thread that listens for REPL commands from the VSCode extension. Architecture: - Extract process_command() from the REPL loop into a shared function that both the interactive REPL and the command server call — single source of truth for force/unforce/get/set commands. - New iec_command_server.hpp: listener thread using Unix domain socket (Linux/macOS) or Win32 Named Pipe (Windows), newline-delimited text protocol with the same command format as the interactive REPL. - New repl-client.ts: TypeScript IPC client using Node.js net module (handles both Unix sockets and Win32 named pipes transparently). - Force/unforce commands now work while the program is running, not just when paused at a breakpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend/repl-main-gen.ts | 16 ++ src/runtime/repl/iec_command_server.hpp | 262 +++++++++++++++++ src/runtime/repl/iec_repl.hpp | 265 ++++++++++++------ vscode-extension/client/src/commands.ts | 11 +- .../client/src/debug-config-builder.ts | 12 +- vscode-extension/client/src/extension.ts | 39 ++- vscode-extension/client/src/force-variable.ts | 135 +++++---- vscode-extension/client/src/repl-client.ts | 181 ++++++++++++ vscode-extension/package.json | 4 +- 9 files changed, 767 insertions(+), 158 deletions(-) create mode 100644 src/runtime/repl/iec_command_server.hpp create mode 100644 vscode-extension/client/src/repl-client.ts diff --git a/src/backend/repl-main-gen.ts b/src/backend/repl-main-gen.ts index 6c4dc41..d0054c6 100644 --- a/src/backend/repl-main-gen.ts +++ b/src/backend/repl-main-gen.ts @@ -120,6 +120,8 @@ export function generateReplMain( lines.push(`#include "${options.headerFileName}"`); lines.push('#include "iec_repl.hpp"'); lines.push('#include "iec_cyclic.hpp"'); + lines.push('#include "iec_command_server.hpp"'); + lines.push("#include "); lines.push(""); lines.push(`using namespace ${ns};`); lines.push("using strucpp::VarTypeTag;"); @@ -277,12 +279,26 @@ function emitProgramDescriptorsAndMain( lines.push("int main(int argc, char* argv[]) {"); lines.push(" bool cyclic = false;"); lines.push(" bool print_vars = false;"); + lines.push(" std::string cmd_pipe_path;"); lines.push(" for (int i = 1; i < argc; ++i) {"); lines.push(' if (std::string(argv[i]) == "--cyclic") cyclic = true;'); lines.push( ' if (std::string(argv[i]) == "--print-vars") print_vars = true;', ); + lines.push( + ' if (std::string(argv[i]) == "--cmd-pipe" && i + 1 < argc) cmd_pipe_path = argv[++i];', + ); lines.push(" }"); + lines.push(""); + lines.push(" // Start IPC command server if pipe path provided"); + lines.push(" std::unique_ptr cmd_server;"); + lines.push(" if (!cmd_pipe_path.empty()) {"); + lines.push( + ` cmd_server = std::make_unique(cmd_pipe_path, programs, ${programs.length});`, + ); + lines.push(" cmd_server->start();"); + lines.push(" }"); + lines.push(""); lines.push(" if (cyclic) {"); lines.push( ` strucpp::cyclic_run(programs, ${programs.length}, print_vars);`, diff --git a/src/runtime/repl/iec_command_server.hpp b/src/runtime/repl/iec_command_server.hpp new file mode 100644 index 0000000..c9aad46 --- /dev/null +++ b/src/runtime/repl/iec_command_server.hpp @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Autonomy / OpenPLC Project +/** + * STruC++ IPC Command Server + * + * Listens on a platform-specific pipe (Unix domain socket on Linux/macOS, + * Named Pipe on Windows) for text commands from the VSCode extension. + * Commands are processed by process_command() from iec_repl.hpp — the same + * code path used by the interactive REPL. + * + * Protocol: newline-delimited text. Each line is a REPL command + * (e.g., "force instance0.STATE 2"). Response is a single line + * starting with "OK:" or "ERR:". + */ + +#pragma once + +#include "iec_repl.hpp" +#include +#include +#include +#include +#include + +// ============================================================================= +// Platform-specific includes +// ============================================================================= + +#if defined(_WIN32) +# include +#else +# include +# include +# include +# include +# include +#endif + +namespace strucpp { + +class CommandServer { +public: + CommandServer(const std::string& pipe_path, + ProgramDescriptor* programs, size_t program_count) + : pipe_path_(pipe_path) + , programs_(programs) + , program_count_(program_count) + {} + + ~CommandServer() { stop(); } + + // Non-copyable + CommandServer(const CommandServer&) = delete; + CommandServer& operator=(const CommandServer&) = delete; + + void start() { + if (running_.load()) return; + running_.store(true); + listener_thread_ = std::thread([this]() { listener_loop(); }); + } + + void stop() { + if (!running_.exchange(false)) return; +#if defined(_WIN32) + // Wake up ConnectNamedPipe by connecting briefly + HANDLE h = CreateFileA(pipe_path_.c_str(), GENERIC_READ, 0, + nullptr, OPEN_EXISTING, 0, nullptr); + if (h != INVALID_HANDLE_VALUE) CloseHandle(h); +#else + if (server_fd_ >= 0) { + shutdown(server_fd_, SHUT_RDWR); + close(server_fd_); + server_fd_ = -1; + } + unlink(pipe_path_.c_str()); +#endif + if (listener_thread_.joinable()) { + listener_thread_.join(); + } + } + +private: + std::string pipe_path_; + ProgramDescriptor* programs_; + size_t program_count_; + std::atomic running_{false}; + std::thread listener_thread_; + +#if !defined(_WIN32) + int server_fd_{-1}; +#endif + + // ========================================================================= + // Platform: Linux / macOS (Unix domain socket) + // ========================================================================= +#if !defined(_WIN32) + + void listener_loop() { + // Remove stale socket from previous crash + unlink(pipe_path_.c_str()); + + server_fd_ = socket(AF_UNIX, SOCK_STREAM, 0); + if (server_fd_ < 0) { + fprintf(stderr, "[cmd-server] socket() failed: %s\n", strerror(errno)); + return; + } + + struct sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, pipe_path_.c_str(), sizeof(addr.sun_path) - 1); + + if (bind(server_fd_, reinterpret_cast(&addr), sizeof(addr)) < 0) { + fprintf(stderr, "[cmd-server] bind(%s) failed: %s\n", + pipe_path_.c_str(), strerror(errno)); + close(server_fd_); + server_fd_ = -1; + return; + } + + if (listen(server_fd_, 1) < 0) { + fprintf(stderr, "[cmd-server] listen() failed: %s\n", strerror(errno)); + close(server_fd_); + server_fd_ = -1; + return; + } + + // Set accept timeout so we can check running_ periodically + struct timeval tv{}; + tv.tv_sec = 0; + tv.tv_usec = 500000; // 500ms + setsockopt(server_fd_, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + + fprintf(stderr, "[cmd-server] Listening on %s\n", pipe_path_.c_str()); + + while (running_.load()) { + int client_fd = accept(server_fd_, nullptr, nullptr); + if (client_fd < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) continue; // timeout + if (!running_.load()) break; // shutting down + fprintf(stderr, "[cmd-server] accept() failed: %s\n", strerror(errno)); + continue; + } + handle_client(client_fd); + close(client_fd); + } + + if (server_fd_ >= 0) { + close(server_fd_); + server_fd_ = -1; + } + unlink(pipe_path_.c_str()); + } + + void handle_client(int client_fd) { + std::string buffer; + char chunk[1024]; + + while (running_.load()) { + ssize_t n = read(client_fd, chunk, sizeof(chunk)); + if (n <= 0) break; // client disconnected or error + + buffer.append(chunk, static_cast(n)); + + // Process complete lines + size_t pos; + while ((pos = buffer.find('\n')) != std::string::npos) { + std::string line = buffer.substr(0, pos); + buffer.erase(0, pos + 1); + + if (line.empty()) continue; + + std::string response = process_command(line, programs_, program_count_); + response += "\n"; + + // Write full response + size_t written = 0; + while (written < response.size()) { + ssize_t w = write(client_fd, response.data() + written, + response.size() - written); + if (w <= 0) return; // write error + written += static_cast(w); + } + } + } + } + + // ========================================================================= + // Platform: Windows (Named Pipe) + // ========================================================================= +#else + + void listener_loop() { + fprintf(stderr, "[cmd-server] Listening on %s\n", pipe_path_.c_str()); + + while (running_.load()) { + HANDLE pipe = CreateNamedPipeA( + pipe_path_.c_str(), + PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, // max instances + 4096, // output buffer + 4096, // input buffer + 500, // default timeout ms (for ConnectNamedPipe) + nullptr // security attributes + ); + + if (pipe == INVALID_HANDLE_VALUE) { + fprintf(stderr, "[cmd-server] CreateNamedPipe failed: %lu\n", GetLastError()); + return; + } + + // Wait for client connection (blocks, but stop() wakes it) + BOOL connected = ConnectNamedPipe(pipe, nullptr) + ? TRUE + : (GetLastError() == ERROR_PIPE_CONNECTED ? TRUE : FALSE); + + if (!running_.load()) { + CloseHandle(pipe); + break; + } + + if (connected) { + handle_client_win(pipe); + } + + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + } + } + + void handle_client_win(HANDLE pipe) { + std::string buffer; + char chunk[1024]; + + while (running_.load()) { + DWORD bytesRead = 0; + BOOL ok = ReadFile(pipe, chunk, sizeof(chunk), &bytesRead, nullptr); + if (!ok || bytesRead == 0) break; + + buffer.append(chunk, bytesRead); + + size_t pos; + while ((pos = buffer.find('\n')) != std::string::npos) { + std::string line = buffer.substr(0, pos); + buffer.erase(0, pos + 1); + + if (line.empty()) continue; + + std::string response = process_command(line, programs_, program_count_); + response += "\n"; + + DWORD written = 0; + WriteFile(pipe, response.data(), + static_cast(response.size()), &written, nullptr); + } + } + } + +#endif // _WIN32 +}; + +} // namespace strucpp diff --git a/src/runtime/repl/iec_repl.hpp b/src/runtime/repl/iec_repl.hpp index fc4ec30..607109b 100644 --- a/src/runtime/repl/iec_repl.hpp +++ b/src/runtime/repl/iec_repl.hpp @@ -323,6 +323,161 @@ inline bool parse_qualified_name(const std::string& input, std::string& prog_nam return true; } +// ============================================================================= +// Shared Command Processor +// ============================================================================= +// Processes force/unforce/get/set/list commands and returns a plain-text response. +// Used by both the interactive REPL loop and the IPC command server. +// Returns "OK: " on success or "ERR: " on failure. + +inline std::string process_command( + const std::string& cmd_line, + ProgramDescriptor* programs, + size_t program_count) +{ + // Parse command and arguments + std::string line = cmd_line; + size_t start = line.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) return "ERR: Empty command"; + size_t end = line.find_last_not_of(" \t\r\n"); + line = line.substr(start, end - start + 1); + if (line.empty()) return "ERR: Empty command"; + + size_t sp = line.find(' '); + std::string cmd = (sp == std::string::npos) ? line : line.substr(0, sp); + std::string args_str = (sp == std::string::npos) ? "" : line.substr(sp + 1); + size_t astart = args_str.find_first_not_of(" \t"); + if (astart != std::string::npos) args_str = args_str.substr(astart); + else args_str.clear(); + + // --- get . --- + if (cmd == "get") { + if (args_str.empty()) return "ERR: Usage: get ."; + std::string pn, vn; + if (!parse_qualified_name(args_str, pn, vn)) return "ERR: Invalid format. Use: program.variable"; + auto* prog = find_program(programs, program_count, pn); + if (!prog) return "ERR: Unknown program: " + pn; + auto* var = find_var(prog, vn); + if (!var) return "ERR: Unknown variable: " + vn + " in " + pn; + bool forced = var_is_forced(var->type, var->var_ptr); + std::string val = var_value_to_string(var->type, var->var_ptr); + return std::string("OK: ") + pn + "." + vn + " : " + + var_type_name(var->type) + " = " + val + (forced ? " [FORCED]" : ""); + } + + // --- set . --- + if (cmd == "set") { + size_t vsp = args_str.find(' '); + if (vsp == std::string::npos) return "ERR: Usage: set . "; + std::string qname = args_str.substr(0, vsp); + std::string val = args_str.substr(vsp + 1); + size_t vs = val.find_first_not_of(" \t"); + if (vs != std::string::npos) val = val.substr(vs); + std::string pn, vn; + if (!parse_qualified_name(qname, pn, vn)) return "ERR: Invalid format. Use: program.variable"; + auto* prog = find_program(programs, program_count, pn); + if (!prog) return "ERR: Unknown program: " + pn; + auto* var = find_var(prog, vn); + if (!var) return "ERR: Unknown variable: " + vn + " in " + pn; + if (var_set_value(var->type, var->var_ptr, val)) { + return std::string("OK: ") + pn + "." + vn + " = " + var_value_to_string(var->type, var->var_ptr); + } + return std::string("ERR: Invalid value for ") + var_type_name(var->type) + ": " + val; + } + + // --- force . --- + if (cmd == "force") { + size_t vsp = args_str.find(' '); + if (vsp == std::string::npos) return "ERR: Usage: force . "; + std::string qname = args_str.substr(0, vsp); + std::string val = args_str.substr(vsp + 1); + size_t vs = val.find_first_not_of(" \t"); + if (vs != std::string::npos) val = val.substr(vs); + std::string pn, vn; + if (!parse_qualified_name(qname, pn, vn)) return "ERR: Invalid format. Use: program.variable"; + auto* prog = find_program(programs, program_count, pn); + if (!prog) return "ERR: Unknown program: " + pn; + auto* var = find_var(prog, vn); + if (!var) return "ERR: Unknown variable: " + vn + " in " + pn; + if (var_force_value(var->type, var->var_ptr, val)) { + return std::string("OK: ") + pn + "." + vn + " FORCED = " + var_value_to_string(var->type, var->var_ptr); + } + return std::string("ERR: Invalid value for ") + var_type_name(var->type) + ": " + val; + } + + // --- unforce . --- + if (cmd == "unforce") { + if (args_str.empty()) return "ERR: Usage: unforce ."; + std::string pn, vn; + if (!parse_qualified_name(args_str, pn, vn)) return "ERR: Invalid format. Use: program.variable"; + auto* prog = find_program(programs, program_count, pn); + if (!prog) return "ERR: Unknown program: " + pn; + auto* var = find_var(prog, vn); + if (!var) return "ERR: Unknown variable: " + vn + " in " + pn; + var_unforce(var->type, var->var_ptr); + return std::string("OK: ") + pn + "." + vn + " unforced. Value: " + var_value_to_string(var->type, var->var_ptr); + } + + // --- unforce_all --- + if (cmd == "unforce_all") { + int count = 0; + for (size_t p = 0; p < program_count; ++p) { + for (size_t i = 0; i < programs[p].var_count; ++i) { + if (var_is_forced(programs[p].vars[i].type, programs[p].vars[i].var_ptr)) { + var_unforce(programs[p].vars[i].type, programs[p].vars[i].var_ptr); + ++count; + } + } + } + return "OK: Unforced " + std::to_string(count) + " variable(s)"; + } + + // --- list_vars [program] --- + if (cmd == "list_vars") { + std::string result = "OK:"; + for (size_t p = 0; p < program_count; ++p) { + if (!args_str.empty() && args_str != programs[p].name) continue; + for (size_t i = 0; i < programs[p].var_count; ++i) { + auto& v = programs[p].vars[i]; + bool forced = var_is_forced(v.type, v.var_ptr); + result += std::string("\n") + programs[p].name + "." + v.name + + " : " + var_type_name(v.type) + " = " + + var_value_to_string(v.type, v.var_ptr) + + (forced ? " [FORCED]" : ""); + } + } + return result; + } + + // --- list_forced --- + if (cmd == "list_forced") { + std::string result = "OK:"; + for (size_t p = 0; p < program_count; ++p) { + for (size_t i = 0; i < programs[p].var_count; ++i) { + auto& v = programs[p].vars[i]; + if (var_is_forced(v.type, v.var_ptr)) { + result += std::string("\n") + programs[p].name + "." + v.name + + " : " + var_type_name(v.type) + " = " + + var_value_to_string(v.type, v.var_ptr); + } + } + } + return result; + } + + // --- programs --- + if (cmd == "programs") { + std::string result = "OK:"; + for (size_t p = 0; p < program_count; ++p) { + result += std::string("\n") + programs[p].name + + " (" + std::to_string(programs[p].var_count) + " vars)"; + } + return result; + } + + return "ERR: Unknown command: " + cmd; +} + // ============================================================================= // Colored Output Helpers // ============================================================================= @@ -702,100 +857,34 @@ inline void repl_run(ProgramDescriptor* programs, size_t program_count, continue; } - // --- vars [program] --- - if (cmd == "vars") { - if (!args_str.empty()) { - auto* prog = find_program(programs, program_count, args_str); - if (!prog) { ic_printf("[red]Unknown program: %s[/]\n", args_str.c_str()); continue; } - for (size_t i = 0; i < prog->var_count; ++i) { - print_var_line(*prog, prog->vars[i]); - } - } else { - for (size_t p = 0; p < program_count; ++p) { - for (size_t i = 0; i < programs[p].var_count; ++i) { - print_var_line(programs[p], programs[p].vars[i]); - } - } - } - continue; - } - - // --- get . --- - if (cmd == "get") { - if (args_str.empty()) { ic_println("[red]Usage: get .[/]"); continue; } - std::string pn, vn; - if (!parse_qualified_name(args_str, pn, vn)) { ic_println("[red]Invalid format. Use: program.variable[/]"); continue; } - auto* prog = find_program(programs, program_count, pn); - if (!prog) { ic_printf("[red]Unknown program: %s[/]\n", pn.c_str()); continue; } - auto* var = find_var(prog, vn); - if (!var) { ic_printf("[red]Unknown variable: %s in %s[/]\n", vn.c_str(), pn.c_str()); continue; } - print_var_line(*prog, *var); - continue; - } - - // --- set . --- - if (cmd == "set") { - size_t vsp = args_str.find(' '); - if (vsp == std::string::npos) { ic_println("[red]Usage: set . [/]"); continue; } - std::string qname = args_str.substr(0, vsp); - std::string val = args_str.substr(vsp + 1); - // Trim val - size_t vs = val.find_first_not_of(" \t"); - if (vs != std::string::npos) val = val.substr(vs); - - std::string pn, vn; - if (!parse_qualified_name(qname, pn, vn)) { ic_println("[red]Invalid format. Use: program.variable[/]"); continue; } - auto* prog = find_program(programs, program_count, pn); - if (!prog) { ic_printf("[red]Unknown program: %s[/]\n", pn.c_str()); continue; } - auto* var = find_var(prog, vn); - if (!var) { ic_printf("[red]Unknown variable: %s in %s[/]\n", vn.c_str(), pn.c_str()); continue; } - if (var_set_value(var->type, var->var_ptr, val)) { - ic_printf(" [b]%s[/].%s = [green]%s[/]\n", pn.c_str(), vn.c_str(), var_value_to_string(var->type, var->var_ptr).c_str()); - } else { - ic_printf("[red]Invalid value for %s: %s[/]\n", var_type_name(var->type), val.c_str()); + // --- vars, get, set, force, unforce, programs --- + // Delegate to shared process_command() for data commands + if (cmd == "vars" || cmd == "get" || cmd == "set" || + cmd == "force" || cmd == "unforce" || cmd == "programs" || + cmd == "list_vars" || cmd == "list_forced" || cmd == "unforce_all") { + // For interactive "vars", map to "list_vars" to use the shared processor + std::string effective_line = line; + if (cmd == "vars") { + effective_line = "list_vars" + (args_str.empty() ? "" : " " + args_str); } - continue; - } - - // --- force . --- - if (cmd == "force") { - size_t vsp = args_str.find(' '); - if (vsp == std::string::npos) { ic_println("[red]Usage: force . [/]"); continue; } - std::string qname = args_str.substr(0, vsp); - std::string val = args_str.substr(vsp + 1); - size_t vs = val.find_first_not_of(" \t"); - if (vs != std::string::npos) val = val.substr(vs); - - std::string pn, vn; - if (!parse_qualified_name(qname, pn, vn)) { ic_println("[red]Invalid format. Use: program.variable[/]"); continue; } - auto* prog = find_program(programs, program_count, pn); - if (!prog) { ic_printf("[red]Unknown program: %s[/]\n", pn.c_str()); continue; } - auto* var = find_var(prog, vn); - if (!var) { ic_printf("[red]Unknown variable: %s in %s[/]\n", vn.c_str(), pn.c_str()); continue; } - if (var_force_value(var->type, var->var_ptr, val)) { - ic_printf(" [b]%s[/].%s [yellow b]FORCED[/] = [green]%s[/]\n", - pn.c_str(), vn.c_str(), var_value_to_string(var->type, var->var_ptr).c_str()); + std::string result = process_command(effective_line, programs, program_count); + // Display with isocline coloring + if (result.substr(0, 3) == "OK:") { + std::string msg = result.substr(3); + if (!msg.empty() && msg[0] == ' ') msg = msg.substr(1); + if (!msg.empty()) { + ic_printf("[green]%s[/]\n", msg.c_str()); + } + } else if (result.substr(0, 4) == "ERR:") { + std::string msg = result.substr(4); + if (!msg.empty() && msg[0] == ' ') msg = msg.substr(1); + ic_printf("[red]%s[/]\n", msg.c_str()); } else { - ic_printf("[red]Invalid value for %s: %s[/]\n", var_type_name(var->type), val.c_str()); + ic_println(result.c_str()); } continue; } - // --- unforce . --- - if (cmd == "unforce") { - if (args_str.empty()) { ic_println("[red]Usage: unforce .[/]"); continue; } - std::string pn, vn; - if (!parse_qualified_name(args_str, pn, vn)) { ic_println("[red]Invalid format. Use: program.variable[/]"); continue; } - auto* prog = find_program(programs, program_count, pn); - if (!prog) { ic_printf("[red]Unknown program: %s[/]\n", pn.c_str()); continue; } - auto* var = find_var(prog, vn); - if (!var) { ic_printf("[red]Unknown variable: %s in %s[/]\n", vn.c_str(), pn.c_str()); continue; } - var_unforce(var->type, var->var_ptr); - ic_printf(" [b]%s[/].%s [green]unforced[/]. Value: [green]%s[/]\n", - pn.c_str(), vn.c_str(), var_value_to_string(var->type, var->var_ptr).c_str()); - continue; - } - // --- code [line] [end] --- if (cmd == "code") { if (source_lines.empty()) { diff --git a/vscode-extension/client/src/commands.ts b/vscode-extension/client/src/commands.ts index 707794a..c5c482c 100644 --- a/vscode-extension/client/src/commands.ts +++ b/vscode-extension/client/src/commands.ts @@ -6,6 +6,7 @@ import * as vscode from "vscode"; import * as path from "node:path"; +import * as os from "node:os"; import * as fs from "node:fs"; import { execFile } from "node:child_process"; import type { LanguageClient } from "vscode-languageclient/node.js"; @@ -33,6 +34,8 @@ export interface DebugBuildState { outputDir: string; lineMap: Array<{ stLine: number; cppStart: number; cppEnd: number }>; sourceUri: string; + /** Path for the IPC command pipe (for variable forcing) */ + cmdPipePath: string; } let _lastDebugBuild: DebugBuildState | undefined; @@ -432,11 +435,17 @@ export async function debugBuildCommand( showWarnings(response); outputChannel.appendLine(`Debug binary built: ${binaryPath}`); + // Generate unique pipe path for IPC command server + const cmdPipePath = process.platform === "win32" + ? `\\\\.\\pipe\\strucpp-cmd-${process.pid}-${Date.now()}` + : path.join(os.tmpdir(), `strucpp-cmd-${process.pid}-${Date.now()}.sock`); + _lastDebugBuild = { binaryPath, outputDir, lineMap: response.lineMap, sourceUri: uri, + cmdPipePath, }; return _lastDebugBuild; @@ -480,7 +489,7 @@ async function launchDebugSession(state: DebugBuildState): Promise { const miMode: "lldb" | "gdb" = isMac ? "lldb" : "gdb"; const setupCommands = buildSetupCommands(miMode); const debugConfig = buildDebugConfig( - { binaryPath: state.binaryPath, outputDir: state.outputDir }, + { binaryPath: state.binaryPath, outputDir: state.outputDir, cmdPipePath: state.cmdPipePath }, debugType, miMode, setupCommands, diff --git a/vscode-extension/client/src/debug-config-builder.ts b/vscode-extension/client/src/debug-config-builder.ts index 0247a7d..543dade 100644 --- a/vscode-extension/client/src/debug-config-builder.ts +++ b/vscode-extension/client/src/debug-config-builder.ts @@ -14,6 +14,8 @@ import type * as vscode from "vscode"; export interface DebugBuildInfo { binaryPath: string; outputDir: string; + /** Path for the IPC command pipe (Unix socket or Win32 named pipe) */ + cmdPipePath?: string; } /** Optional user overrides from launch.json or debug config provider. */ @@ -137,6 +139,10 @@ export function buildDebugConfig( overrides?: DebugConfigOverrides, ): vscode.DebugConfiguration { const name = overrides?.name || "Debug ST Program"; + const args = ["--cyclic"]; + if (build.cmdPipePath) { + args.push("--cmd-pipe", build.cmdPipePath); + } if (debugType === "lldb") { return { @@ -144,9 +150,10 @@ export function buildDebugConfig( request: "launch", name, program: build.binaryPath, - args: ["--cyclic"], + args, cwd: build.outputDir, __strucpp: true, + __cmdPipePath: build.cmdPipePath, initCommands: getLLDBInitCommands(), ...(overrides?.env ? { env: overrides.env } : {}), ...(overrides?.stopOnEntry ? { stopOnEntry: true } : {}), @@ -158,9 +165,10 @@ export function buildDebugConfig( request: "launch", name, program: build.binaryPath, - args: ["--cyclic"], + args, cwd: build.outputDir, __strucpp: true, + __cmdPipePath: build.cmdPipePath, MIMode: miMode, setupCommands, ...(overrides?.env diff --git a/vscode-extension/client/src/extension.ts b/vscode-extension/client/src/extension.ts index 0360121..5968715 100644 --- a/vscode-extension/client/src/extension.ts +++ b/vscode-extension/client/src/extension.ts @@ -30,6 +30,7 @@ import { unforceVariableCommand, unforceAllCommand, } from "./force-variable.js"; +import { ReplClient } from "./repl-client.js"; import { LibrariesChangedNotification } from "../../shared/protocol.js"; let client: LanguageClient | undefined; @@ -133,22 +134,54 @@ export function activate(context: ExtensionContext): void { vscode.debug.registerDebugAdapterTrackerFactory("lldb", trackerFactory), ); + // ReplClient for IPC with the running debug binary + const replClient = new ReplClient(); + // Forced variables panel and commands const forcedProvider = new ForcedVariablesProvider(); context.subscriptions.push( vscode.window.registerTreeDataProvider("strucpp.forcedVariables", forcedProvider), vscode.commands.registerCommand("strucpp.forceVariable", (args) => - forceVariableCommand(args, forcedProvider), + forceVariableCommand(args, forcedProvider, replClient), ), vscode.commands.registerCommand("strucpp.unforceVariable", (args) => - unforceVariableCommand(args, forcedProvider), + unforceVariableCommand(args, forcedProvider, replClient), ), vscode.commands.registerCommand("strucpp.unforceAll", () => - unforceAllCommand(forcedProvider), + unforceAllCommand(forcedProvider, replClient), ), forcedProvider, ); + // Connect/disconnect ReplClient with debug session lifecycle + context.subscriptions.push( + vscode.debug.onDidStartDebugSession(async (session) => { + const config = session.configuration as Record; + if (!config.__strucpp) return; + const pipePath = config.__cmdPipePath as string | undefined; + if (pipePath) { + // Delay slightly to let the binary start and create the pipe + setTimeout(async () => { + try { + await replClient.connect(pipePath); + } catch (err) { + console.warn("[strucpp] Failed to connect to command server:", err); + } + }, 500); + } + }), + vscode.debug.onDidTerminateDebugSession((session) => { + const config = session.configuration as Record; + if (!config.__strucpp) return; + replClient.disconnect(); + // Clean up pipe file + const pipePath = config.__cmdPipePath as string | undefined; + if (pipePath) { + try { require("fs").unlinkSync(pipePath); } catch { /* already cleaned up */ } + } + }), + ); + // Test Explorer integration const testController = new StrucppTestController(context, client!); context.subscriptions.push(testController); diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index 01ece4b..062fd25 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -3,22 +3,45 @@ /** * Force/Unforce variable commands and Forced Variables panel. * - * Uses IECVar::force(value) and IECVar::unforce() C++ methods - * via the debug adapter's evaluate request. + * Routes all force/unforce operations through the ReplClient IPC pipe, + * which calls the same process_command() used by the interactive REPL. */ import * as vscode from "vscode"; +import { ReplClient } from "./repl-client.js"; /** A variable that has been forced to a specific value. */ export interface ForcedVariableEntry { - /** C++ evaluate path (e.g. "TICK_TIMER.IN") */ - evaluateName: string; + /** REPL path (e.g. "instance0.STATE") */ + replPath: string; /** Display name shown in the panel */ displayName: string; /** The value the variable is forced to */ forcedValue: string; - /** C++ type (for display) */ - type?: string; +} + +/** + * Convert a C++ evaluateName from the debugger to a REPL variable path. + * + * Patterns: + * "config_Config0.instance0.STATE" → "instance0.STATE" + * "prog_Main.COUNTER" → "Main.COUNTER" + */ +function evaluateNameToReplPath(evaluateName: string): string { + const parts = evaluateName.split("."); + + if (parts.length >= 3 && parts[0]!.startsWith("config_")) { + // Configuration mode: config_X.instanceName.VAR → instanceName.VAR + return parts.slice(1).join("."); + } + + if (parts.length >= 2 && parts[0]!.startsWith("prog_")) { + // Standalone mode: prog_Name.VAR → Name.VAR + return parts[0]!.substring(5) + "." + parts.slice(1).join("."); + } + + // Fallback: use as-is + return evaluateName; } /** @@ -28,10 +51,12 @@ export interface ForcedVariableEntry { export async function forceVariableCommand( args: { variable?: { evaluateName?: string; name?: string; value?: string } }, provider: ForcedVariablesProvider, + replClient?: ReplClient, ): Promise { - const session = vscode.debug.activeDebugSession; - if (!session) { - vscode.window.showWarningMessage("No active debug session."); + if (!replClient?.isConnected()) { + vscode.window.showWarningMessage( + "Not connected to the running program. Start a debug session first.", + ); return; } @@ -50,14 +75,18 @@ export async function forceVariableCommand( if (value === undefined) return; // cancelled + const replPath = evaluateNameToReplPath(evaluateName); + try { - await session.customRequest("evaluate", { - expression: `${evaluateName}.force(${value})`, - context: "repl", - }); + const response = await replClient.sendCommand(`force ${replPath} ${value}`); + const result = ReplClient.parseResponse(response); + if (!result.ok) { + vscode.window.showErrorMessage(`Failed to force variable: ${result.message}`); + return; + } provider.addForced({ - evaluateName, + replPath, displayName: args.variable?.name ?? evaluateName, forcedValue: value, }); @@ -75,27 +104,29 @@ export async function forceVariableCommand( export async function unforceVariableCommand( args: { variable?: { evaluateName?: string }; entry?: ForcedVariableEntry }, provider: ForcedVariablesProvider, + replClient?: ReplClient, ): Promise { - const session = vscode.debug.activeDebugSession; - if (!session) { - vscode.window.showWarningMessage("No active debug session."); + if (!replClient?.isConnected()) { + vscode.window.showWarningMessage("Not connected to the running program."); return; } - // From Forced Variables panel (TreeItem) or Variables pane context menu - const evaluateName = args?.entry?.evaluateName ?? args?.variable?.evaluateName; - if (!evaluateName) { - vscode.window.showWarningMessage("Cannot unforce this variable — no evaluate path available."); + const replPath = args?.entry?.replPath + ?? (args?.variable?.evaluateName ? evaluateNameToReplPath(args.variable.evaluateName) : undefined); + if (!replPath) { + vscode.window.showWarningMessage("Cannot unforce this variable — no path available."); return; } try { - await session.customRequest("evaluate", { - expression: `${evaluateName}.unforce()`, - context: "repl", - }); + const response = await replClient.sendCommand(`unforce ${replPath}`); + const result = ReplClient.parseResponse(response); + if (!result.ok) { + vscode.window.showErrorMessage(`Failed to unforce variable: ${result.message}`); + return; + } - provider.removeForced(evaluateName); + provider.removeForced(replPath); } catch (err) { vscode.window.showErrorMessage( `Failed to unforce variable: ${err instanceof Error ? err.message : String(err)}`, @@ -108,34 +139,26 @@ export async function unforceVariableCommand( */ export async function unforceAllCommand( provider: ForcedVariablesProvider, + replClient?: ReplClient, ): Promise { - const session = vscode.debug.activeDebugSession; - if (!session) { - vscode.window.showWarningMessage("No active debug session."); + if (!replClient?.isConnected()) { + vscode.window.showWarningMessage("Not connected to the running program."); return; } - const entries = provider.getEntries(); - const errors: string[] = []; - - for (const entry of entries) { - try { - await session.customRequest("evaluate", { - expression: `${entry.evaluateName}.unforce()`, - context: "repl", - }); - } catch (err) { - errors.push(`${entry.displayName}: ${err instanceof Error ? err.message : String(err)}`); + try { + const response = await replClient.sendCommand("unforce_all"); + const result = ReplClient.parseResponse(response); + if (!result.ok) { + vscode.window.showWarningMessage(`Failed to unforce all: ${result.message}`); } - } - - provider.clearAll(); - - if (errors.length > 0) { + } catch (err) { vscode.window.showWarningMessage( - `Some variables could not be unforced:\n${errors.join("\n")}`, + `Some variables could not be unforced: ${err instanceof Error ? err.message : String(err)}`, ); } + + provider.clearAll(); } /** @@ -168,10 +191,8 @@ export class ForcedVariablesProvider vscode.TreeItemCollapsibleState.None, ); item.iconPath = new vscode.ThemeIcon("lock"); - item.tooltip = `${element.evaluateName} forced to ${element.forcedValue}`; + item.tooltip = `${element.replPath} forced to ${element.forcedValue}`; item.contextValue = "forcedVariable"; - // Pass the entry as command argument for inline unforce button - item.command = undefined; return item; } @@ -180,19 +201,9 @@ export class ForcedVariablesProvider return this.entries; } - /** Get the entry for a tree item (used by unforce command). */ - getEntryByEvaluateName(evaluateName: string): ForcedVariableEntry | undefined { - return this.entries.find((e) => e.evaluateName === evaluateName); - } - - /** Get all entries (used by unforce all). */ - getEntries(): readonly ForcedVariableEntry[] { - return this.entries; - } - addForced(entry: ForcedVariableEntry): void { // Update existing or add new - const idx = this.entries.findIndex((e) => e.evaluateName === entry.evaluateName); + const idx = this.entries.findIndex((e) => e.replPath === entry.replPath); if (idx >= 0) { this.entries[idx] = entry; } else { @@ -201,8 +212,8 @@ export class ForcedVariablesProvider this._onDidChangeTreeData.fire(); } - removeForced(evaluateName: string): void { - const idx = this.entries.findIndex((e) => e.evaluateName === evaluateName); + removeForced(replPath: string): void { + const idx = this.entries.findIndex((e) => e.replPath === replPath); if (idx >= 0) { this.entries.splice(idx, 1); this._onDidChangeTreeData.fire(); diff --git a/vscode-extension/client/src/repl-client.ts b/vscode-extension/client/src/repl-client.ts new file mode 100644 index 0000000..2d2da85 --- /dev/null +++ b/vscode-extension/client/src/repl-client.ts @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Autonomy / OpenPLC Project +/** + * ReplClient — IPC client for the STruC++ command server. + * + * Connects to the debug binary's command pipe (Unix domain socket on + * Linux/macOS, Named Pipe on Windows) and sends REPL commands as plain text. + * The protocol is newline-delimited: one command per line, one response per line. + * + * Commands use the same format as the interactive REPL: + * "force instance0.STATE 2" → "OK: instance0.STATE FORCED = 2" + * "get instance0.STATE" → "OK: instance0.STATE : INT = 2 [FORCED]" + * "unforce instance0.STATE" → "OK: instance0.STATE unforced. Value: 0" + */ + +import * as net from "node:net"; + +/** Result from a command that may succeed or fail. */ +export interface CommandResult { + ok: boolean; + message: string; +} + +export class ReplClient { + private socket: net.Socket | null = null; + private connected = false; + private responseBuffer = ""; + private pendingResolve: ((line: string) => void) | null = null; + private pendingReject: ((err: Error) => void) | null = null; + + /** + * Connect to the command server pipe. + * Retries with exponential backoff (up to ~3s total) to allow the + * binary time to start and create the pipe. + */ + async connect(pipePath: string): Promise { + const maxRetries = 6; + const baseDelay = 100; // ms + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await this.tryConnect(pipePath); + return; + } catch { + if (attempt < maxRetries - 1) { + await new Promise((r) => setTimeout(r, baseDelay * Math.pow(2, attempt))); + } + } + } + throw new Error(`Failed to connect to command server at ${pipePath}`); + } + + private tryConnect(pipePath: string): Promise { + return new Promise((resolve, reject) => { + const sock = net.createConnection({ path: pipePath }, () => { + this.socket = sock; + this.connected = true; + this.responseBuffer = ""; + resolve(); + }); + + sock.on("data", (data: Buffer) => { + this.responseBuffer += data.toString(); + this.drainBuffer(); + }); + + sock.on("close", () => { + this.connected = false; + this.socket = null; + if (this.pendingReject) { + this.pendingReject(new Error("Connection closed")); + this.pendingResolve = null; + this.pendingReject = null; + } + }); + + sock.on("error", (err: Error) => { + this.connected = false; + this.socket = null; + if (this.pendingReject) { + this.pendingReject(err); + this.pendingResolve = null; + this.pendingReject = null; + } + reject(err); + }); + + sock.setTimeout(5000, () => { + sock.destroy(); + reject(new Error("Connection timeout")); + }); + }); + } + + /** Process buffered data and resolve pending command if a complete line arrived. */ + private drainBuffer(): void { + const nlIndex = this.responseBuffer.indexOf("\n"); + if (nlIndex >= 0 && this.pendingResolve) { + const line = this.responseBuffer.substring(0, nlIndex); + this.responseBuffer = this.responseBuffer.substring(nlIndex + 1); + const resolve = this.pendingResolve; + this.pendingResolve = null; + this.pendingReject = null; + resolve(line); + } + } + + disconnect(): void { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + this.connected = false; + this.responseBuffer = ""; + if (this.pendingReject) { + this.pendingReject(new Error("Disconnected")); + this.pendingResolve = null; + this.pendingReject = null; + } + } + + isConnected(): boolean { + return this.connected; + } + + /** + * Send a REPL command and wait for the response line. + * @param command Full REPL command (e.g., "force instance0.STATE 2") + * @returns The response line (e.g., "OK: instance0.STATE FORCED = 2") + */ + async sendCommand(command: string): Promise { + if (!this.socket || !this.connected) { + throw new Error("Not connected to command server"); + } + + if (this.pendingResolve) { + throw new Error("A command is already in progress"); + } + + return new Promise((resolve, reject) => { + this.pendingResolve = resolve; + this.pendingReject = reject; + + // Timeout after 5 seconds + const timeout = setTimeout(() => { + if (this.pendingReject) { + this.pendingReject(new Error("Command timed out")); + this.pendingResolve = null; + this.pendingReject = null; + } + }, 5000); + + // Clear timeout when resolved + const originalResolve = this.pendingResolve; + this.pendingResolve = (line: string) => { + clearTimeout(timeout); + originalResolve(line); + }; + + this.socket!.write(command + "\n"); + + // Check if response already in buffer + this.drainBuffer(); + }); + } + + /** + * Parse a response line into a CommandResult. + * "OK: ..." → {ok: true, message: "..."} + * "ERR: ..." → {ok: false, message: "..."} + */ + static parseResponse(response: string): CommandResult { + if (response.startsWith("OK:")) { + return { ok: true, message: response.substring(3).trimStart() }; + } + if (response.startsWith("ERR:")) { + return { ok: false, message: response.substring(4).trimStart() }; + } + return { ok: false, message: response }; + } +} diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 7806c78..873cbb7 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -152,12 +152,12 @@ "debug/variables/context": [ { "command": "strucpp.forceVariable", - "when": "debugState == stopped", + "when": "inDebugMode", "group": "3_strucpp@1" }, { "command": "strucpp.unforceVariable", - "when": "debugState == stopped", + "when": "inDebugMode", "group": "3_strucpp@2" } ], From 183545c67998dccf43a11e17e02aece88daa9cef Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Apr 2026 14:29:24 -0400 Subject: [PATCH 02/17] fix: pass cmdPipePath through debug config provider The resolveDebugConfiguration() path in debug-config-provider.ts was not passing cmdPipePath to buildDebugConfig(), so the debug binary was launched without --cmd-pipe and the command server never started. Also increase the connect delay to 1s and show a warning toast if the connection fails. Co-Authored-By: Claude Opus 4.6 (1M context) --- vscode-extension/client/src/debug-config-provider.ts | 2 +- vscode-extension/client/src/extension.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/vscode-extension/client/src/debug-config-provider.ts b/vscode-extension/client/src/debug-config-provider.ts index 2d38701..b5a001f 100644 --- a/vscode-extension/client/src/debug-config-provider.ts +++ b/vscode-extension/client/src/debug-config-provider.ts @@ -70,7 +70,7 @@ export class StrucppDebugConfigProvider const setupCommands = buildSetupCommands(miMode, prettyPrinterPath); return buildDebugConfig( - { binaryPath: debugState.binaryPath, outputDir: debugState.outputDir }, + { binaryPath: debugState.binaryPath, outputDir: debugState.outputDir, cmdPipePath: debugState.cmdPipePath }, debugType, miMode, setupCommands, diff --git a/vscode-extension/client/src/extension.ts b/vscode-extension/client/src/extension.ts index 5968715..589fa37 100644 --- a/vscode-extension/client/src/extension.ts +++ b/vscode-extension/client/src/extension.ts @@ -160,14 +160,19 @@ export function activate(context: ExtensionContext): void { if (!config.__strucpp) return; const pipePath = config.__cmdPipePath as string | undefined; if (pipePath) { - // Delay slightly to let the binary start and create the pipe + // Delay to let the binary start and create the command pipe setTimeout(async () => { try { await replClient.connect(pipePath); + console.log("[strucpp] Connected to command server at", pipePath); } catch (err) { - console.warn("[strucpp] Failed to connect to command server:", err); + const msg = err instanceof Error ? err.message : String(err); + console.error("[strucpp] Failed to connect to command server:", msg); + vscode.window.showWarningMessage( + `STruC++: Could not connect to debug binary for variable forcing. ${msg}`, + ); } - }, 500); + }, 1000); } }), vscode.debug.onDidTerminateDebugSession((session) => { From 0c0683a2159f424e02c94237c4cc08dfb966448d Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Apr 2026 14:34:38 -0400 Subject: [PATCH 03/17] debug: add logging throughout variable forcing IPC chain Add diagnostic logging to trace exactly where the pipe connection breaks. Logs go to the "STruC++ Debug" output channel and console: - commands.ts: logs generated pipe path - debug-config-provider.ts: logs debugState.cmdPipePath - debug-config-builder.ts: logs args passed to binary - extension.ts: logs session config, connect attempt, success/failure - force-variable.ts: logs replClient state when force is invoked Check Output > "STruC++ Debug" panel after launching a debug session to see the full trace. Co-Authored-By: Claude Opus 4.6 (1M context) --- vscode-extension/client/src/commands.ts | 1 + .../client/src/debug-config-builder.ts | 1 + .../client/src/debug-config-provider.ts | 2 + vscode-extension/client/src/extension.ts | 37 +++++++++++-------- vscode-extension/client/src/force-variable.ts | 1 + 5 files changed, 27 insertions(+), 15 deletions(-) diff --git a/vscode-extension/client/src/commands.ts b/vscode-extension/client/src/commands.ts index c5c482c..4a72335 100644 --- a/vscode-extension/client/src/commands.ts +++ b/vscode-extension/client/src/commands.ts @@ -439,6 +439,7 @@ export async function debugBuildCommand( const cmdPipePath = process.platform === "win32" ? `\\\\.\\pipe\\strucpp-cmd-${process.pid}-${Date.now()}` : path.join(os.tmpdir(), `strucpp-cmd-${process.pid}-${Date.now()}.sock`); + outputChannel.appendLine(`[repl-client] Generated cmd pipe path: ${cmdPipePath}`); _lastDebugBuild = { binaryPath, diff --git a/vscode-extension/client/src/debug-config-builder.ts b/vscode-extension/client/src/debug-config-builder.ts index 543dade..6cf5251 100644 --- a/vscode-extension/client/src/debug-config-builder.ts +++ b/vscode-extension/client/src/debug-config-builder.ts @@ -143,6 +143,7 @@ export function buildDebugConfig( if (build.cmdPipePath) { args.push("--cmd-pipe", build.cmdPipePath); } + console.log(`[strucpp:debug-config] buildDebugConfig: cmdPipePath=${build.cmdPipePath ?? "NONE"}, args=${JSON.stringify(args)}`); if (debugType === "lldb") { return { diff --git a/vscode-extension/client/src/debug-config-provider.ts b/vscode-extension/client/src/debug-config-provider.ts index b5a001f..74edc6c 100644 --- a/vscode-extension/client/src/debug-config-provider.ts +++ b/vscode-extension/client/src/debug-config-provider.ts @@ -62,6 +62,8 @@ export class StrucppDebugConfigProvider return undefined; } + console.log(`[strucpp:debug-provider] debugState.cmdPipePath=${debugState.cmdPipePath}`); + const isMac = process.platform === "darwin"; const hasCodeLLDB = vscode.extensions.getExtension("vadimcn.vscode-lldb") != null; const debugType = isMac && hasCodeLLDB ? "lldb" : "cppdbg"; diff --git a/vscode-extension/client/src/extension.ts b/vscode-extension/client/src/extension.ts index 589fa37..0fb232a 100644 --- a/vscode-extension/client/src/extension.ts +++ b/vscode-extension/client/src/extension.ts @@ -35,6 +35,7 @@ import { LibrariesChangedNotification } from "../../shared/protocol.js"; let client: LanguageClient | undefined; let statusBarItem: vscode.StatusBarItem; +const outputChannel = vscode.window.createOutputChannel("STruC++ Debug"); function updateStatusBar(item: vscode.StatusBarItem, explorer: StlibExplorer): void { const count = explorer.libraryCount; @@ -157,23 +158,29 @@ export function activate(context: ExtensionContext): void { context.subscriptions.push( vscode.debug.onDidStartDebugSession(async (session) => { const config = session.configuration as Record; - if (!config.__strucpp) return; + outputChannel.appendLine(`[repl-client] Debug session started. type=${config.type} __strucpp=${config.__strucpp} __cmdPipePath=${config.__cmdPipePath}`); + if (!config.__strucpp) { + outputChannel.appendLine("[repl-client] Not a STruC++ session, skipping pipe connect."); + return; + } const pipePath = config.__cmdPipePath as string | undefined; - if (pipePath) { - // Delay to let the binary start and create the command pipe - setTimeout(async () => { - try { - await replClient.connect(pipePath); - console.log("[strucpp] Connected to command server at", pipePath); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error("[strucpp] Failed to connect to command server:", msg); - vscode.window.showWarningMessage( - `STruC++: Could not connect to debug binary for variable forcing. ${msg}`, - ); - } - }, 1000); + if (!pipePath) { + outputChannel.appendLine("[repl-client] No __cmdPipePath in debug config — command server will not be available."); + return; } + outputChannel.appendLine(`[repl-client] Will connect to ${pipePath} in 1s...`); + setTimeout(async () => { + try { + await replClient.connect(pipePath); + outputChannel.appendLine(`[repl-client] Connected to command server at ${pipePath}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + outputChannel.appendLine(`[repl-client] FAILED to connect: ${msg}`); + vscode.window.showWarningMessage( + `STruC++: Could not connect to debug binary for variable forcing. ${msg}`, + ); + } + }, 1000); }), vscode.debug.onDidTerminateDebugSession((session) => { const config = session.configuration as Record; diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index 062fd25..a2f1ba3 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -53,6 +53,7 @@ export async function forceVariableCommand( provider: ForcedVariablesProvider, replClient?: ReplClient, ): Promise { + console.log("[strucpp:force] forceVariableCommand called. replClient exists:", !!replClient, "connected:", replClient?.isConnected(), "args:", JSON.stringify(args?.variable)); if (!replClient?.isConnected()) { vscode.window.showWarningMessage( "Not connected to the running program. Start a debug session first.", From 490aaf285e78e54fd8ba4af6087e275f1808997e Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Apr 2026 14:43:01 -0400 Subject: [PATCH 04/17] fix: socket timeout destroys idle connection, server blocks on dead client Two bugs in the IPC connection: 1. ReplClient: net.Socket.setTimeout(5000) fires on idle data, not just during connection. Once connected, the socket gets destroyed after 5s of no commands. Fix: disable timeout after successful connection with sock.setTimeout(0). 2. CommandServer: handle_client() read() blocks indefinitely if client disconnects without closing cleanly. Fix: set SO_RCVTIMEO on client socket so read() returns EAGAIN on timeout, allowing the loop to check running_ and accept new connections. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/runtime/repl/iec_command_server.hpp | 12 +++++++++++- vscode-extension/client/src/repl-client.ts | 18 +++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/runtime/repl/iec_command_server.hpp b/src/runtime/repl/iec_command_server.hpp index c9aad46..b268d29 100644 --- a/src/runtime/repl/iec_command_server.hpp +++ b/src/runtime/repl/iec_command_server.hpp @@ -152,12 +152,22 @@ class CommandServer { } void handle_client(int client_fd) { + // Set read timeout so a dead client doesn't block the server forever + struct timeval tv{}; + tv.tv_sec = 1; + tv.tv_usec = 0; + setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + std::string buffer; char chunk[1024]; while (running_.load()) { ssize_t n = read(client_fd, chunk, sizeof(chunk)); - if (n <= 0) break; // client disconnected or error + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) continue; // read timeout, check running_ + break; // real error + } + if (n == 0) break; // client disconnected buffer.append(chunk, static_cast(n)); diff --git a/vscode-extension/client/src/repl-client.ts b/vscode-extension/client/src/repl-client.ts index 2d2da85..5d89ab8 100644 --- a/vscode-extension/client/src/repl-client.ts +++ b/vscode-extension/client/src/repl-client.ts @@ -52,7 +52,12 @@ export class ReplClient { private tryConnect(pipePath: string): Promise { return new Promise((resolve, reject) => { + let settled = false; + const sock = net.createConnection({ path: pipePath }, () => { + settled = true; + // Disable the connection timeout now that we're connected + sock.setTimeout(0); this.socket = sock; this.connected = true; this.responseBuffer = ""; @@ -82,12 +87,19 @@ export class ReplClient { this.pendingResolve = null; this.pendingReject = null; } - reject(err); + if (!settled) { + settled = true; + reject(err); + } }); + // Timeout only for the initial connection attempt sock.setTimeout(5000, () => { - sock.destroy(); - reject(new Error("Connection timeout")); + if (!settled) { + settled = true; + sock.destroy(); + reject(new Error("Connection timeout")); + } }); }); } From 2d58e01374cd7b2ea952d6a6393881679bbca6ff Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Apr 2026 15:01:35 -0400 Subject: [PATCH 05/17] debug: add per-attempt logging to connect retries, check socket existence Log each retry attempt with whether the socket file exists on disk. This will reveal whether the binary hasn't created the socket yet, or whether the socket exists but connection fails. Also increase retries to 8 and base delay to 250ms for more total wait time (~64s max). Co-Authored-By: Claude Opus 4.6 (1M context) --- vscode-extension/client/src/repl-client.ts | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/vscode-extension/client/src/repl-client.ts b/vscode-extension/client/src/repl-client.ts index 5d89ab8..edd5a3b 100644 --- a/vscode-extension/client/src/repl-client.ts +++ b/vscode-extension/client/src/repl-client.ts @@ -34,18 +34,25 @@ export class ReplClient { * binary time to start and create the pipe. */ async connect(pipePath: string): Promise { - const maxRetries = 6; - const baseDelay = 100; // ms + const maxRetries = 8; + const baseDelay = 250; // ms + const fs = await import("node:fs"); for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - await this.tryConnect(pipePath); - return; - } catch { - if (attempt < maxRetries - 1) { - await new Promise((r) => setTimeout(r, baseDelay * Math.pow(2, attempt))); + const exists = fs.existsSync(pipePath); + console.log(`[repl-client] connect attempt ${attempt + 1}/${maxRetries}, socket exists: ${exists}`); + if (exists) { + try { + await this.tryConnect(pipePath); + return; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log(`[repl-client] attempt ${attempt + 1} failed: ${msg}`); } } + if (attempt < maxRetries - 1) { + await new Promise((r) => setTimeout(r, baseDelay * Math.pow(2, attempt))); + } } throw new Error(`Failed to connect to command server at ${pipePath}`); } From 0247fe8fc0d56dafe7dec6751142660352f5ad8c Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Apr 2026 08:33:32 -0400 Subject: [PATCH 06/17] fix: lazy connect on first force command, not at session start GDB's all-stop mode freezes all threads (including the command server) when any thread hits a breakpoint. Connecting at session start races with GDB pausing the binary during startup, causing ECONNREFUSED. Fix: defer the connection to the first force/unforce command via ensureConnected(). By the time the user right-clicks a variable, the program has been running long enough for the command server to be active. If the program is paused at a breakpoint when the user tries to force, the connection will fail because GDB has frozen the server thread. The error message now tells the user to resume execution first. Also found: cppdbg provides evaluateName as GDB cast expressions like ((strucpp::TYPE *)0xaddr)->VAR, not clean paths. This needs separate handling (tracked for follow-up). Co-Authored-By: Claude Opus 4.6 (1M context) --- vscode-extension/client/src/extension.ts | 82 ++++++++++--------- vscode-extension/client/src/force-variable.ts | 4 +- 2 files changed, 47 insertions(+), 39 deletions(-) diff --git a/vscode-extension/client/src/extension.ts b/vscode-extension/client/src/extension.ts index 0fb232a..a322ee7 100644 --- a/vscode-extension/client/src/extension.ts +++ b/vscode-extension/client/src/extension.ts @@ -142,58 +142,66 @@ export function activate(context: ExtensionContext): void { const forcedProvider = new ForcedVariablesProvider(); context.subscriptions.push( vscode.window.registerTreeDataProvider("strucpp.forcedVariables", forcedProvider), - vscode.commands.registerCommand("strucpp.forceVariable", (args) => - forceVariableCommand(args, forcedProvider, replClient), - ), - vscode.commands.registerCommand("strucpp.unforceVariable", (args) => - unforceVariableCommand(args, forcedProvider, replClient), - ), - vscode.commands.registerCommand("strucpp.unforceAll", () => - unforceAllCommand(forcedProvider, replClient), - ), + vscode.commands.registerCommand("strucpp.forceVariable", async (args) => { + await ensureConnected(); + return forceVariableCommand(args, forcedProvider, replClient); + }), + vscode.commands.registerCommand("strucpp.unforceVariable", async (args) => { + await ensureConnected(); + return unforceVariableCommand(args, forcedProvider, replClient); + }), + vscode.commands.registerCommand("strucpp.unforceAll", async () => { + await ensureConnected(); + return unforceAllCommand(forcedProvider, replClient); + }), forcedProvider, ); - // Connect/disconnect ReplClient with debug session lifecycle + // Track the command pipe path for the active debug session. + // Connection is lazy — established on first force/unforce command, + // not at session start. This avoids races with GDB pausing all + // threads during startup. + let activePipePath: string | undefined; + context.subscriptions.push( - vscode.debug.onDidStartDebugSession(async (session) => { + vscode.debug.onDidStartDebugSession((session) => { const config = session.configuration as Record; - outputChannel.appendLine(`[repl-client] Debug session started. type=${config.type} __strucpp=${config.__strucpp} __cmdPipePath=${config.__cmdPipePath}`); - if (!config.__strucpp) { - outputChannel.appendLine("[repl-client] Not a STruC++ session, skipping pipe connect."); - return; - } - const pipePath = config.__cmdPipePath as string | undefined; - if (!pipePath) { - outputChannel.appendLine("[repl-client] No __cmdPipePath in debug config — command server will not be available."); - return; - } - outputChannel.appendLine(`[repl-client] Will connect to ${pipePath} in 1s...`); - setTimeout(async () => { - try { - await replClient.connect(pipePath); - outputChannel.appendLine(`[repl-client] Connected to command server at ${pipePath}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - outputChannel.appendLine(`[repl-client] FAILED to connect: ${msg}`); - vscode.window.showWarningMessage( - `STruC++: Could not connect to debug binary for variable forcing. ${msg}`, - ); - } - }, 1000); + if (!config.__strucpp) return; + activePipePath = config.__cmdPipePath as string | undefined; + outputChannel.appendLine(`[repl-client] Debug session started. pipePath=${activePipePath ?? "NONE"}`); }), vscode.debug.onDidTerminateDebugSession((session) => { const config = session.configuration as Record; if (!config.__strucpp) return; replClient.disconnect(); // Clean up pipe file - const pipePath = config.__cmdPipePath as string | undefined; - if (pipePath) { - try { require("fs").unlinkSync(pipePath); } catch { /* already cleaned up */ } + if (activePipePath) { + try { require("fs").unlinkSync(activePipePath); } catch { /* already cleaned up */ } } + activePipePath = undefined; }), ); + /** + * Ensure the ReplClient is connected. Called lazily before each + * force/unforce command. This avoids the race condition where GDB + * pauses all threads (including the command server) during startup. + */ + async function ensureConnected(): Promise { + if (replClient.isConnected()) return true; + if (!activePipePath) return false; + try { + outputChannel.appendLine(`[repl-client] Connecting to ${activePipePath}...`); + await replClient.connect(activePipePath); + outputChannel.appendLine(`[repl-client] Connected.`); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + outputChannel.appendLine(`[repl-client] Connection failed: ${msg}`); + return false; + } + } + // Test Explorer integration const testController = new StrucppTestController(context, client!); context.subscriptions.push(testController); diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index a2f1ba3..ab4a4a8 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -53,10 +53,10 @@ export async function forceVariableCommand( provider: ForcedVariablesProvider, replClient?: ReplClient, ): Promise { - console.log("[strucpp:force] forceVariableCommand called. replClient exists:", !!replClient, "connected:", replClient?.isConnected(), "args:", JSON.stringify(args?.variable)); if (!replClient?.isConnected()) { vscode.window.showWarningMessage( - "Not connected to the running program. Start a debug session first.", + "Cannot force variable — not connected to the running program. " + + "If the program is paused at a breakpoint, resume it first (F5) and try again.", ); return; } From e136337580bf5e9cb8b1ff2f1ecd8b7c57af0d15 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Apr 2026 08:56:46 -0400 Subject: [PATCH 07/17] feat: force variables via GDB field writes instead of IPC pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the command server IPC approach with direct GDB memory writes for variable forcing. When stopped at a breakpoint, the extension sets the IECVar internal fields directly via DAP evaluate: evaluateName.forced_ = true evaluateName.forced_value_ = evaluateName.value_ = GDB ignores C++ access specifiers and can write private struct fields. This works reliably when stopped at a breakpoint — the primary use case since the Variables pane is only populated when stopped. The command server (iec_command_server.hpp) and ReplClient are kept in the codebase for future use (Option D hybrid: GDB when stopped, pipe when running), but the force commands no longer depend on them. Co-Authored-By: Claude Opus 4.6 (1M context) --- vscode-extension/client/src/extension.ts | 71 +------- vscode-extension/client/src/force-variable.ts | 154 ++++++++++-------- vscode-extension/package.json | 4 +- 3 files changed, 96 insertions(+), 133 deletions(-) diff --git a/vscode-extension/client/src/extension.ts b/vscode-extension/client/src/extension.ts index a322ee7..0360121 100644 --- a/vscode-extension/client/src/extension.ts +++ b/vscode-extension/client/src/extension.ts @@ -30,12 +30,10 @@ import { unforceVariableCommand, unforceAllCommand, } from "./force-variable.js"; -import { ReplClient } from "./repl-client.js"; import { LibrariesChangedNotification } from "../../shared/protocol.js"; let client: LanguageClient | undefined; let statusBarItem: vscode.StatusBarItem; -const outputChannel = vscode.window.createOutputChannel("STruC++ Debug"); function updateStatusBar(item: vscode.StatusBarItem, explorer: StlibExplorer): void { const count = explorer.libraryCount; @@ -135,73 +133,22 @@ export function activate(context: ExtensionContext): void { vscode.debug.registerDebugAdapterTrackerFactory("lldb", trackerFactory), ); - // ReplClient for IPC with the running debug binary - const replClient = new ReplClient(); - // Forced variables panel and commands const forcedProvider = new ForcedVariablesProvider(); context.subscriptions.push( vscode.window.registerTreeDataProvider("strucpp.forcedVariables", forcedProvider), - vscode.commands.registerCommand("strucpp.forceVariable", async (args) => { - await ensureConnected(); - return forceVariableCommand(args, forcedProvider, replClient); - }), - vscode.commands.registerCommand("strucpp.unforceVariable", async (args) => { - await ensureConnected(); - return unforceVariableCommand(args, forcedProvider, replClient); - }), - vscode.commands.registerCommand("strucpp.unforceAll", async () => { - await ensureConnected(); - return unforceAllCommand(forcedProvider, replClient); - }), + vscode.commands.registerCommand("strucpp.forceVariable", (args) => + forceVariableCommand(args, forcedProvider), + ), + vscode.commands.registerCommand("strucpp.unforceVariable", (args) => + unforceVariableCommand(args, forcedProvider), + ), + vscode.commands.registerCommand("strucpp.unforceAll", () => + unforceAllCommand(forcedProvider), + ), forcedProvider, ); - // Track the command pipe path for the active debug session. - // Connection is lazy — established on first force/unforce command, - // not at session start. This avoids races with GDB pausing all - // threads during startup. - let activePipePath: string | undefined; - - context.subscriptions.push( - vscode.debug.onDidStartDebugSession((session) => { - const config = session.configuration as Record; - if (!config.__strucpp) return; - activePipePath = config.__cmdPipePath as string | undefined; - outputChannel.appendLine(`[repl-client] Debug session started. pipePath=${activePipePath ?? "NONE"}`); - }), - vscode.debug.onDidTerminateDebugSession((session) => { - const config = session.configuration as Record; - if (!config.__strucpp) return; - replClient.disconnect(); - // Clean up pipe file - if (activePipePath) { - try { require("fs").unlinkSync(activePipePath); } catch { /* already cleaned up */ } - } - activePipePath = undefined; - }), - ); - - /** - * Ensure the ReplClient is connected. Called lazily before each - * force/unforce command. This avoids the race condition where GDB - * pauses all threads (including the command server) during startup. - */ - async function ensureConnected(): Promise { - if (replClient.isConnected()) return true; - if (!activePipePath) return false; - try { - outputChannel.appendLine(`[repl-client] Connecting to ${activePipePath}...`); - await replClient.connect(activePipePath); - outputChannel.appendLine(`[repl-client] Connected.`); - return true; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - outputChannel.appendLine(`[repl-client] Connection failed: ${msg}`); - return false; - } - } - // Test Explorer integration const testController = new StrucppTestController(context, client!); context.subscriptions.push(testController); diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index ab4a4a8..4f2f032 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -3,17 +3,24 @@ /** * Force/Unforce variable commands and Forced Variables panel. * - * Routes all force/unforce operations through the ReplClient IPC pipe, - * which calls the same process_command() used by the interactive REPL. + * Forces variables by writing directly to IECVar internal fields via + * the debug adapter's evaluate request. GDB/LLDB can set struct fields + * without needing to call template methods: + * + * evaluateName.forced_ = true + * evaluateName.forced_value_ = + * evaluateName.value_ = + * + * This works when stopped at a breakpoint (the primary use case) because + * GDB has full access to process memory and ignores C++ access specifiers. */ import * as vscode from "vscode"; -import { ReplClient } from "./repl-client.js"; /** A variable that has been forced to a specific value. */ export interface ForcedVariableEntry { - /** REPL path (e.g. "instance0.STATE") */ - replPath: string; + /** GDB evaluateName for the variable */ + evaluateName: string; /** Display name shown in the panel */ displayName: string; /** The value the variable is forced to */ @@ -21,27 +28,43 @@ export interface ForcedVariableEntry { } /** - * Convert a C++ evaluateName from the debugger to a REPL variable path. - * - * Patterns: - * "config_Config0.instance0.STATE" → "instance0.STATE" - * "prog_Main.COUNTER" → "Main.COUNTER" + * Execute a GDB/LLDB expression via the debug adapter. + * Returns the result string, or throws on failure. */ -function evaluateNameToReplPath(evaluateName: string): string { - const parts = evaluateName.split("."); - - if (parts.length >= 3 && parts[0]!.startsWith("config_")) { - // Configuration mode: config_X.instanceName.VAR → instanceName.VAR - return parts.slice(1).join("."); - } +async function debugEvaluate( + session: vscode.DebugSession, + expression: string, +): Promise { + const result = await session.customRequest("evaluate", { + expression, + context: "repl", + }); + return result?.result ?? ""; +} - if (parts.length >= 2 && parts[0]!.startsWith("prog_")) { - // Standalone mode: prog_Name.VAR → Name.VAR - return parts[0]!.substring(5) + "." + parts.slice(1).join("."); - } +/** + * Force a variable by writing to its IECVar internal fields via GDB. + * Sets forced_ = true, forced_value_ = value, value_ = value. + */ +async function forceViaDebugger( + session: vscode.DebugSession, + evaluateName: string, + value: string, +): Promise { + // Write all three fields — GDB ignores C++ access specifiers + await debugEvaluate(session, `${evaluateName}.forced_ = true`); + await debugEvaluate(session, `${evaluateName}.forced_value_ = ${value}`); + await debugEvaluate(session, `${evaluateName}.value_ = ${value}`); +} - // Fallback: use as-is - return evaluateName; +/** + * Unforce a variable by clearing its IECVar forced_ flag via GDB. + */ +async function unforceViaDebugger( + session: vscode.DebugSession, + evaluateName: string, +): Promise { + await debugEvaluate(session, `${evaluateName}.forced_ = false`); } /** @@ -51,13 +74,10 @@ function evaluateNameToReplPath(evaluateName: string): string { export async function forceVariableCommand( args: { variable?: { evaluateName?: string; name?: string; value?: string } }, provider: ForcedVariablesProvider, - replClient?: ReplClient, ): Promise { - if (!replClient?.isConnected()) { - vscode.window.showWarningMessage( - "Cannot force variable — not connected to the running program. " + - "If the program is paused at a breakpoint, resume it first (F5) and try again.", - ); + const session = vscode.debug.activeDebugSession; + if (!session) { + vscode.window.showWarningMessage("No active debug session."); return; } @@ -76,18 +96,11 @@ export async function forceVariableCommand( if (value === undefined) return; // cancelled - const replPath = evaluateNameToReplPath(evaluateName); - try { - const response = await replClient.sendCommand(`force ${replPath} ${value}`); - const result = ReplClient.parseResponse(response); - if (!result.ok) { - vscode.window.showErrorMessage(`Failed to force variable: ${result.message}`); - return; - } + await forceViaDebugger(session, evaluateName, value); provider.addForced({ - replPath, + evaluateName, displayName: args.variable?.name ?? evaluateName, forcedValue: value, }); @@ -105,29 +118,22 @@ export async function forceVariableCommand( export async function unforceVariableCommand( args: { variable?: { evaluateName?: string }; entry?: ForcedVariableEntry }, provider: ForcedVariablesProvider, - replClient?: ReplClient, ): Promise { - if (!replClient?.isConnected()) { - vscode.window.showWarningMessage("Not connected to the running program."); + const session = vscode.debug.activeDebugSession; + if (!session) { + vscode.window.showWarningMessage("No active debug session."); return; } - const replPath = args?.entry?.replPath - ?? (args?.variable?.evaluateName ? evaluateNameToReplPath(args.variable.evaluateName) : undefined); - if (!replPath) { - vscode.window.showWarningMessage("Cannot unforce this variable — no path available."); + const evaluateName = args?.entry?.evaluateName ?? args?.variable?.evaluateName; + if (!evaluateName) { + vscode.window.showWarningMessage("Cannot unforce this variable — no evaluate path available."); return; } try { - const response = await replClient.sendCommand(`unforce ${replPath}`); - const result = ReplClient.parseResponse(response); - if (!result.ok) { - vscode.window.showErrorMessage(`Failed to unforce variable: ${result.message}`); - return; - } - - provider.removeForced(replPath); + await unforceViaDebugger(session, evaluateName); + provider.removeForced(evaluateName); } catch (err) { vscode.window.showErrorMessage( `Failed to unforce variable: ${err instanceof Error ? err.message : String(err)}`, @@ -140,26 +146,31 @@ export async function unforceVariableCommand( */ export async function unforceAllCommand( provider: ForcedVariablesProvider, - replClient?: ReplClient, ): Promise { - if (!replClient?.isConnected()) { - vscode.window.showWarningMessage("Not connected to the running program."); + const session = vscode.debug.activeDebugSession; + if (!session) { + vscode.window.showWarningMessage("No active debug session."); return; } - try { - const response = await replClient.sendCommand("unforce_all"); - const result = ReplClient.parseResponse(response); - if (!result.ok) { - vscode.window.showWarningMessage(`Failed to unforce all: ${result.message}`); + const entries = provider.getEntries(); + const errors: string[] = []; + + for (const entry of entries) { + try { + await unforceViaDebugger(session, entry.evaluateName); + } catch (err) { + errors.push(`${entry.displayName}: ${err instanceof Error ? err.message : String(err)}`); } - } catch (err) { - vscode.window.showWarningMessage( - `Some variables could not be unforced: ${err instanceof Error ? err.message : String(err)}`, - ); } provider.clearAll(); + + if (errors.length > 0) { + vscode.window.showWarningMessage( + `Some variables could not be unforced:\n${errors.join("\n")}`, + ); + } } /** @@ -192,7 +203,7 @@ export class ForcedVariablesProvider vscode.TreeItemCollapsibleState.None, ); item.iconPath = new vscode.ThemeIcon("lock"); - item.tooltip = `${element.replPath} forced to ${element.forcedValue}`; + item.tooltip = `Forced to ${element.forcedValue}`; item.contextValue = "forcedVariable"; return item; } @@ -202,9 +213,14 @@ export class ForcedVariablesProvider return this.entries; } + /** Get all entries (used by unforce all). */ + getEntries(): readonly ForcedVariableEntry[] { + return this.entries; + } + addForced(entry: ForcedVariableEntry): void { // Update existing or add new - const idx = this.entries.findIndex((e) => e.replPath === entry.replPath); + const idx = this.entries.findIndex((e) => e.evaluateName === entry.evaluateName); if (idx >= 0) { this.entries[idx] = entry; } else { @@ -213,8 +229,8 @@ export class ForcedVariablesProvider this._onDidChangeTreeData.fire(); } - removeForced(replPath: string): void { - const idx = this.entries.findIndex((e) => e.replPath === replPath); + removeForced(evaluateName: string): void { + const idx = this.entries.findIndex((e) => e.evaluateName === evaluateName); if (idx >= 0) { this.entries.splice(idx, 1); this._onDidChangeTreeData.fire(); diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 873cbb7..7806c78 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -152,12 +152,12 @@ "debug/variables/context": [ { "command": "strucpp.forceVariable", - "when": "inDebugMode", + "when": "debugState == stopped", "group": "3_strucpp@1" }, { "command": "strucpp.unforceVariable", - "when": "inDebugMode", + "when": "debugState == stopped", "group": "3_strucpp@2" } ], From c4564159eac262700bf41f9b30392fb8367dc2c0 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Apr 2026 09:34:50 -0400 Subject: [PATCH 08/17] fix: provide stack frame context for GDB evaluate requests The DAP evaluate request requires a frameId to specify which stack frame the expression should be evaluated in. Without it, cppdbg returns "Cannot evaluate expression on the specified stack frame". Fix: before evaluating, fetch the active thread's topmost stack frame and pass its ID to the evaluate request. Co-Authored-By: Claude Opus 4.6 (1M context) --- vscode-extension/client/src/force-variable.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index 4f2f032..9e1f278 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -29,15 +29,36 @@ export interface ForcedVariableEntry { /** * Execute a GDB/LLDB expression via the debug adapter. + * Uses the topmost stack frame of the active thread for context. * Returns the result string, or throws on failure. */ async function debugEvaluate( session: vscode.DebugSession, expression: string, ): Promise { + // Get the active thread's topmost stack frame for evaluation context + let frameId: number | undefined; + try { + const threads = await session.customRequest("threads"); + if (threads?.threads?.length > 0) { + const threadId = threads.threads[0].id; + const stack = await session.customRequest("stackTrace", { + threadId, + startFrame: 0, + levels: 1, + }); + if (stack?.stackFrames?.length > 0) { + frameId = stack.stackFrames[0].id; + } + } + } catch { + // If we can't get a frame, try without one + } + const result = await session.customRequest("evaluate", { expression, context: "repl", + ...(frameId !== undefined ? { frameId } : {}), }); return result?.result ?? ""; } From 8339ede2cee4b8c19c547f40fe85f8c25767c78b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Apr 2026 09:42:26 -0400 Subject: [PATCH 09/17] debug: log GDB evaluate results for force field writes --- vscode-extension/client/src/force-variable.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index 9e1f278..9478819 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -73,9 +73,16 @@ async function forceViaDebugger( value: string, ): Promise { // Write all three fields — GDB ignores C++ access specifiers - await debugEvaluate(session, `${evaluateName}.forced_ = true`); - await debugEvaluate(session, `${evaluateName}.forced_value_ = ${value}`); - await debugEvaluate(session, `${evaluateName}.value_ = ${value}`); + const r1 = await debugEvaluate(session, `${evaluateName}.forced_ = true`); + const r2 = await debugEvaluate(session, `${evaluateName}.forced_value_ = ${value}`); + const r3 = await debugEvaluate(session, `${evaluateName}.value_ = ${value}`); + console.log(`[strucpp:force] evaluateName=${evaluateName}`); + console.log(`[strucpp:force] forced_=true → ${r1}`); + console.log(`[strucpp:force] forced_value_=${value} → ${r2}`); + console.log(`[strucpp:force] value_=${value} → ${r3}`); + // Verify: read back the value + const verify = await debugEvaluate(session, `${evaluateName}.forced_`); + console.log(`[strucpp:force] verify forced_=${verify}`); } /** From 439ede2e7432fc79c3c132b330e139d19b729abe Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Apr 2026 09:56:59 -0400 Subject: [PATCH 10/17] fix: use 'variables' context for GDB evaluate to bypass ST uppercasing The debug adapter tracker transforms evaluate requests with context "repl" or "watch" by uppercasing identifiers (ST is case-insensitive). This breaks force field writes because C++ namespaces are case- sensitive: strucpp:: becomes STRUCPP:: which LLDB can't resolve. Fix: use context "variables" for force/unforce evaluate requests. The tracker only transforms "repl" and "watch" contexts, so "variables" passes through untouched. Co-Authored-By: Claude Opus 4.6 (1M context) --- vscode-extension/client/src/force-variable.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index 9478819..acd08b5 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -55,9 +55,12 @@ async function debugEvaluate( // If we can't get a frame, try without one } + // Use context "variables" to bypass the debug adapter tracker's + // ST-to-C++ expression transformation (which uppercases identifiers + // and would break C++ namespace resolution like strucpp::). const result = await session.customRequest("evaluate", { expression, - context: "repl", + context: "variables", ...(frameId !== undefined ? { frameId } : {}), }); return result?.result ?? ""; From 74a85de74d901ed3eb32b828e22d21f226c9d878 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Apr 2026 09:59:40 -0400 Subject: [PATCH 11/17] chore: remove debug logging from force-variable Remove console.log statements and verify read-back that were added during debugging. The force mechanism is confirmed working. Co-Authored-By: Claude Opus 4.6 (1M context) --- vscode-extension/client/src/force-variable.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index acd08b5..53bb254 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -76,16 +76,9 @@ async function forceViaDebugger( value: string, ): Promise { // Write all three fields — GDB ignores C++ access specifiers - const r1 = await debugEvaluate(session, `${evaluateName}.forced_ = true`); - const r2 = await debugEvaluate(session, `${evaluateName}.forced_value_ = ${value}`); - const r3 = await debugEvaluate(session, `${evaluateName}.value_ = ${value}`); - console.log(`[strucpp:force] evaluateName=${evaluateName}`); - console.log(`[strucpp:force] forced_=true → ${r1}`); - console.log(`[strucpp:force] forced_value_=${value} → ${r2}`); - console.log(`[strucpp:force] value_=${value} → ${r3}`); - // Verify: read back the value - const verify = await debugEvaluate(session, `${evaluateName}.forced_`); - console.log(`[strucpp:force] verify forced_=${verify}`); + await debugEvaluate(session, `${evaluateName}.forced_ = true`); + await debugEvaluate(session, `${evaluateName}.forced_value_ = ${value}`); + await debugEvaluate(session, `${evaluateName}.value_ = ${value}`); } /** From e2ce484c1759916fb2076e36246d224c67623b93 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Apr 2026 10:04:28 -0400 Subject: [PATCH 12/17] fix: prevent timer burst after resuming from debugger breakpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When GDB pauses the program at a breakpoint, wall clock time advances while the cyclic loop is frozen. On resume, the sleep_until target (next_tick) is in the past, so the loop runs at full speed without sleeping — executing hundreds of cycles instantly. This causes timers (TON, TOF, TP) to fire almost immediately instead of respecting their configured delay. Fix: after each cycle, check if next_tick has fallen behind the current wall clock. If so, reset it to now. This prevents catch-up bursts while still maintaining accurate timing during normal execution. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/runtime/repl/iec_cyclic.hpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/runtime/repl/iec_cyclic.hpp b/src/runtime/repl/iec_cyclic.hpp index a53de2b..a43850c 100644 --- a/src/runtime/repl/iec_cyclic.hpp +++ b/src/runtime/repl/iec_cyclic.hpp @@ -94,8 +94,14 @@ inline void cyclic_run(ProgramDescriptor* programs, size_t program_count, fflush(stdout); } - // Sleep until next tick (drift-free) + // Sleep until next tick. + // If we've fallen behind (e.g., resumed from a debugger breakpoint), + // reset to now to prevent a burst of catch-up cycles. next_tick += std::chrono::nanoseconds(common_ticktime); + auto now = std::chrono::steady_clock::now(); + if (next_tick < now) { + next_tick = now; + } std::this_thread::sleep_until(next_tick); } From c188f9725668b2fc4ce40f7638a49253b9066500 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Apr 2026 11:51:41 -0400 Subject: [PATCH 13/17] fix: refresh variables after force, intercept Set Value for IECVar Three fixes for the debug variable interaction: 1. After forcing a variable, issue a dummy evaluate in "repl" context to trigger cppdbg's variable cache invalidation. This makes the forced value visible immediately in the Variables pane without needing to step. 2. Intercept setExpression DAP requests (triggered by "Set Value" in the Watch pane) and rewrite them as evaluate requests that assign to .value_ directly. This avoids the "expression could not be evaluated" error caused by cppdbg trying to assign to the IECVar wrapper type. 3. Note: "Set Value" in the Variables pane sends setVariable (not setExpression). This is harder to intercept since it uses variablesReference IDs rather than expression paths. For now, users should use "STruC++: Force Variable" from the context menu for the Variables pane. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/src/debug-adapter-tracker.ts | 36 +++++++++++++++++++ vscode-extension/client/src/force-variable.ts | 12 +++++++ 2 files changed, 48 insertions(+) diff --git a/vscode-extension/client/src/debug-adapter-tracker.ts b/vscode-extension/client/src/debug-adapter-tracker.ts index 81c0071..0548d62 100644 --- a/vscode-extension/client/src/debug-adapter-tracker.ts +++ b/vscode-extension/client/src/debug-adapter-tracker.ts @@ -71,6 +71,42 @@ class StrucppDebugTracker implements vscode.DebugAdapterTracker { } } } + + // Intercept "Set Value" (setVariable/setExpression) for IECVar types. + // VSCode sends these when the user edits a value in the Variables or + // Watch pane. For IECVar-wrapped variables, rewrite as a .value_ field + // assignment since the debug adapter can't assign to the wrapper directly. + const setMsg = message as { + type?: string; + command?: string; + arguments?: { + name?: string; + value?: string; + variablesReference?: number; + expression?: string; + frameId?: number; + }; + }; + if ( + setMsg.type === "request" && + (setMsg.command === "setVariable" || setMsg.command === "setExpression") && + setMsg.arguments?.value !== undefined + ) { + // We can't easily detect if the target is an IECVar from here, + // so rewrite setExpression to an evaluate that sets value_ directly. + // For setVariable, the adapter handles it; for setExpression (watch pane), + // we convert to an evaluate request. + if (setMsg.command === "setExpression" && setMsg.arguments.expression) { + const expr = setMsg.arguments.expression; + const val = setMsg.arguments.value; + // Rewrite: setExpression → evaluate with .value_ assignment + setMsg.command = "evaluate"; + (setMsg.arguments as Record).expression = `${expr}.value_ = ${val}`; + (setMsg.arguments as Record).context = "variables"; + delete (setMsg.arguments as Record).value; + log.appendLine(`[tracker] setExpression → evaluate: ${expr}.value_ = ${val}`); + } + } } /** diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index 53bb254..517afbe 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -79,6 +79,18 @@ async function forceViaDebugger( await debugEvaluate(session, `${evaluateName}.forced_ = true`); await debugEvaluate(session, `${evaluateName}.forced_value_ = ${value}`); await debugEvaluate(session, `${evaluateName}.value_ = ${value}`); + + // Trigger a Variables pane refresh so the new value is visible immediately. + // cppdbg invalidates its variable cache after any evaluate in "repl" context. + // Issue a harmless read expression to force the refresh. + try { + await session.customRequest("evaluate", { + expression: "0", + context: "repl", + }); + } catch { + // Best-effort — the force already succeeded + } } /** From 876b03cda69a6c2c040b58ecc7112433028ea5df Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Apr 2026 13:03:53 -0400 Subject: [PATCH 14/17] debug: log all DAP requests through tracker, force refresh result Log every request passing through the debug adapter tracker to see exactly what VSCode sends for Set Value (setVariable vs setExpression), and what the refresh evaluate returns after forcing. Check the "STruC++ Debug" output channel for [tracker] lines and Developer Tools Console for [strucpp:force] lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/src/debug-adapter-tracker.ts | 63 +++++++++---------- vscode-extension/client/src/force-variable.ts | 9 ++- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/vscode-extension/client/src/debug-adapter-tracker.ts b/vscode-extension/client/src/debug-adapter-tracker.ts index 0548d62..a32bc3b 100644 --- a/vscode-extension/client/src/debug-adapter-tracker.ts +++ b/vscode-extension/client/src/debug-adapter-tracker.ts @@ -47,23 +47,27 @@ class StrucppDebugTracker implements vscode.DebugAdapterTracker { const msg = message as { type?: string; command?: string; - arguments?: { - expression?: string; - context?: string; - }; + seq?: number; + arguments?: Record; }; + // Log all requests for debugging + if (msg.type === "request") { + const args = msg.arguments ? JSON.stringify(msg.arguments).substring(0, 200) : "{}"; + log.appendLine(`[tracker] → ${msg.command} (seq=${msg.seq}) args=${args}`); + } + if ( msg.type === "request" && msg.command === "evaluate" && msg.arguments?.expression ) { - const ctx = msg.arguments.context; + const ctx = msg.arguments.context as string | undefined; // Transform watch and REPL expressions. // Skip "hover" — handled by EvaluatableExpressionProvider which // also consults the language server for type-aware .value_ appending. if (ctx === "watch" || ctx === "repl") { - const original = msg.arguments.expression; + const original = msg.arguments.expression as string; const transformed = transformStExpression(original); if (transformed !== original) { log.appendLine(`[tracker] ${ctx}: "${original}" → "${transformed}"`); @@ -73,38 +77,27 @@ class StrucppDebugTracker implements vscode.DebugAdapterTracker { } // Intercept "Set Value" (setVariable/setExpression) for IECVar types. - // VSCode sends these when the user edits a value in the Variables or - // Watch pane. For IECVar-wrapped variables, rewrite as a .value_ field - // assignment since the debug adapter can't assign to the wrapper directly. - const setMsg = message as { - type?: string; - command?: string; - arguments?: { - name?: string; - value?: string; - variablesReference?: number; - expression?: string; - frameId?: number; - }; - }; if ( - setMsg.type === "request" && - (setMsg.command === "setVariable" || setMsg.command === "setExpression") && - setMsg.arguments?.value !== undefined + msg.type === "request" && + (msg.command === "setVariable" || msg.command === "setExpression") ) { - // We can't easily detect if the target is an IECVar from here, - // so rewrite setExpression to an evaluate that sets value_ directly. - // For setVariable, the adapter handles it; for setExpression (watch pane), - // we convert to an evaluate request. - if (setMsg.command === "setExpression" && setMsg.arguments.expression) { - const expr = setMsg.arguments.expression; - const val = setMsg.arguments.value; + log.appendLine(`[tracker] Intercepted ${msg.command}: ${JSON.stringify(msg.arguments)}`); + + if (msg.command === "setExpression" && msg.arguments?.expression && msg.arguments?.value !== undefined) { + const expr = msg.arguments.expression as string; + const val = msg.arguments.value as string; // Rewrite: setExpression → evaluate with .value_ assignment - setMsg.command = "evaluate"; - (setMsg.arguments as Record).expression = `${expr}.value_ = ${val}`; - (setMsg.arguments as Record).context = "variables"; - delete (setMsg.arguments as Record).value; - log.appendLine(`[tracker] setExpression → evaluate: ${expr}.value_ = ${val}`); + msg.command = "evaluate"; + msg.arguments.expression = `${expr}.value_ = ${val}`; + msg.arguments.context = "variables"; + delete msg.arguments.value; + log.appendLine(`[tracker] Rewrote to evaluate: ${msg.arguments.expression}`); + } + + if (msg.command === "setVariable" && msg.arguments?.name && msg.arguments?.value !== undefined) { + log.appendLine(`[tracker] setVariable for "${msg.arguments.name}" = "${msg.arguments.value}" (variablesReference=${msg.arguments.variablesReference})`); + // TODO: setVariable uses variablesReference IDs, not expression paths. + // Can't easily rewrite without knowing the evaluateName. } } } diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index 517afbe..5ec2b2e 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -81,15 +81,14 @@ async function forceViaDebugger( await debugEvaluate(session, `${evaluateName}.value_ = ${value}`); // Trigger a Variables pane refresh so the new value is visible immediately. - // cppdbg invalidates its variable cache after any evaluate in "repl" context. - // Issue a harmless read expression to force the refresh. try { - await session.customRequest("evaluate", { + const refreshResult = await session.customRequest("evaluate", { expression: "0", context: "repl", }); - } catch { - // Best-effort — the force already succeeded + console.log("[strucpp:force] refresh evaluate result:", JSON.stringify(refreshResult)); + } catch (err) { + console.log("[strucpp:force] refresh evaluate failed:", err instanceof Error ? err.message : String(err)); } } From 5449f558d89f3437e812546ff1ec9ffa4a5928d5 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Apr 2026 16:58:39 -0400 Subject: [PATCH 15/17] fix: refresh uses debugEvaluate with frameId, uppercase setExpression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes based on DAP trace analysis: 1. Variable refresh after force: the "repl" context evaluate failed because it had no frameId. Replace with a read-back of the forced value via debugEvaluate() which always includes a frameId. This also uses "variables" context to avoid the ST uppercasing. 2. setExpression rewrite (Watch pane "Set Value"): the expression wasn't being uppercased, so LLDB couldn't find the ST variable name. Now applies transformStExpression() before rewriting. 3. setVariable (Variables pane "Set Value"): logged but left to cppdbg — it uses variablesReference IDs which we can't map to evaluateNames without caching the variable tree. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/src/debug-adapter-tracker.ts | 10 ++++++---- vscode-extension/client/src/force-variable.ts | 13 +++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/vscode-extension/client/src/debug-adapter-tracker.ts b/vscode-extension/client/src/debug-adapter-tracker.ts index a32bc3b..4868d00 100644 --- a/vscode-extension/client/src/debug-adapter-tracker.ts +++ b/vscode-extension/client/src/debug-adapter-tracker.ts @@ -84,20 +84,22 @@ class StrucppDebugTracker implements vscode.DebugAdapterTracker { log.appendLine(`[tracker] Intercepted ${msg.command}: ${JSON.stringify(msg.arguments)}`); if (msg.command === "setExpression" && msg.arguments?.expression && msg.arguments?.value !== undefined) { - const expr = msg.arguments.expression as string; + const expr = transformStExpression(msg.arguments.expression as string); const val = msg.arguments.value as string; // Rewrite: setExpression → evaluate with .value_ assignment + // Use uppercase C++ name and "variables" context to bypass further transformation msg.command = "evaluate"; msg.arguments.expression = `${expr}.value_ = ${val}`; msg.arguments.context = "variables"; delete msg.arguments.value; - log.appendLine(`[tracker] Rewrote to evaluate: ${msg.arguments.expression}`); + log.appendLine(`[tracker] setExpression → evaluate: ${msg.arguments.expression}`); } if (msg.command === "setVariable" && msg.arguments?.name && msg.arguments?.value !== undefined) { log.appendLine(`[tracker] setVariable for "${msg.arguments.name}" = "${msg.arguments.value}" (variablesReference=${msg.arguments.variablesReference})`); - // TODO: setVariable uses variablesReference IDs, not expression paths. - // Can't easily rewrite without knowing the evaluateName. + // setVariable uses variablesReference IDs. We can't easily rewrite + // without the evaluateName. Let cppdbg attempt it — it may work for + // simple types or fail gracefully for IECVar wrappers. } } } diff --git a/vscode-extension/client/src/force-variable.ts b/vscode-extension/client/src/force-variable.ts index 5ec2b2e..f886fcb 100644 --- a/vscode-extension/client/src/force-variable.ts +++ b/vscode-extension/client/src/force-variable.ts @@ -80,15 +80,12 @@ async function forceViaDebugger( await debugEvaluate(session, `${evaluateName}.forced_value_ = ${value}`); await debugEvaluate(session, `${evaluateName}.value_ = ${value}`); - // Trigger a Variables pane refresh so the new value is visible immediately. + // Trigger a Variables pane refresh by re-reading the value we just set. + // This causes cppdbg to invalidate its cached variable display. try { - const refreshResult = await session.customRequest("evaluate", { - expression: "0", - context: "repl", - }); - console.log("[strucpp:force] refresh evaluate result:", JSON.stringify(refreshResult)); - } catch (err) { - console.log("[strucpp:force] refresh evaluate failed:", err instanceof Error ? err.message : String(err)); + await debugEvaluate(session, `${evaluateName}.value_`); + } catch { + // Best-effort — the force already succeeded } } From e4194cc1074bf515fcd5641799fe03ec587f5e6b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 14 Apr 2026 08:38:03 -0400 Subject: [PATCH 16/17] feat: intercept Set Value for Variables pane via evaluateName cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Variables pane "Set Value" sends a setVariable DAP request with only variablesReference + name (no evaluateName). To rewrite this as an evaluate that sets .value_ directly, cache the evaluateName for each variable when the variables response passes through. Implementation: - Track pending variables requests (seq → variablesReference) - On variables response, cache (variablesReference, name) → evaluateName - On setVariable, look up the cached evaluateName and rewrite to an evaluate request: evaluateName.value_ = - Cache lastFrameId from stackTrace responses to provide frame context Also applies to setExpression (Watch pane Set Value) which now correctly uppercases the ST expression. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/src/debug-adapter-tracker.ts | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/vscode-extension/client/src/debug-adapter-tracker.ts b/vscode-extension/client/src/debug-adapter-tracker.ts index 4868d00..37aadc3 100644 --- a/vscode-extension/client/src/debug-adapter-tracker.ts +++ b/vscode-extension/client/src/debug-adapter-tracker.ts @@ -39,6 +39,24 @@ export class StrucppDebugTrackerFactory } class StrucppDebugTracker implements vscode.DebugAdapterTracker { + /** + * Cache: maps (variablesReference, name) → evaluateName. + * Populated from variables responses, used to resolve setVariable requests. + */ + /** + * Cache: maps (variablesReference, name) → evaluateName. + * Populated from variables responses, used to resolve setVariable requests. + */ + private varEvalNameCache = new Map(); + /** Track pending variables requests: seq → variablesReference */ + private pendingVarRequests = new Map(); + /** Last seen top frame ID (for evaluate requests that need a frame context) */ + private lastFrameId: number | undefined; + + private cacheKey(variablesReference: number, name: string): string { + return `${variablesReference}:${name}`; + } + /** * Intercept requests FROM VSCode TO the debug adapter. * Transform watch/REPL expressions from ST naming to C++ naming. @@ -57,6 +75,14 @@ class StrucppDebugTracker implements vscode.DebugAdapterTracker { log.appendLine(`[tracker] → ${msg.command} (seq=${msg.seq}) args=${args}`); } + // Track variables requests so we can cache evaluateNames from responses + if (msg.type === "request" && msg.command === "variables" && msg.seq !== undefined) { + const varRef = msg.arguments?.variablesReference as number | undefined; + if (varRef !== undefined) { + this.pendingVarRequests.set(msg.seq, varRef); + } + } + if ( msg.type === "request" && msg.command === "evaluate" && @@ -96,10 +122,23 @@ class StrucppDebugTracker implements vscode.DebugAdapterTracker { } if (msg.command === "setVariable" && msg.arguments?.name && msg.arguments?.value !== undefined) { - log.appendLine(`[tracker] setVariable for "${msg.arguments.name}" = "${msg.arguments.value}" (variablesReference=${msg.arguments.variablesReference})`); - // setVariable uses variablesReference IDs. We can't easily rewrite - // without the evaluateName. Let cppdbg attempt it — it may work for - // simple types or fail gracefully for IECVar wrappers. + const varRef = msg.arguments.variablesReference as number | undefined; + const name = msg.arguments.name as string; + const val = msg.arguments.value as string; + const evalName = varRef !== undefined + ? this.varEvalNameCache.get(this.cacheKey(varRef, name)) + : undefined; + log.appendLine(`[tracker] setVariable for "${name}" = "${val}" (ref=${varRef}, evalName=${evalName ?? "NOT CACHED"})`); + if (evalName) { + // Rewrite: setVariable → evaluate with .value_ field assignment + msg.command = "evaluate"; + msg.arguments = { + expression: `${evalName}.value_ = ${val}`, + context: "variables", + ...(this.lastFrameId !== undefined ? { frameId: this.lastFrameId } : {}), + }; + log.appendLine(`[tracker] Rewrote to evaluate: ${msg.arguments.expression} (frameId=${this.lastFrameId})`); + } } } } @@ -112,6 +151,7 @@ class StrucppDebugTracker implements vscode.DebugAdapterTracker { const msg = message as { type?: string; command?: string; + request_seq?: number; body?: { variables?: DAPVariable[]; // evaluate response fields @@ -125,8 +165,28 @@ class StrucppDebugTracker implements vscode.DebugAdapterTracker { if (msg.type !== "response" || !msg.body) return; + // Cache the top frame ID from stackTrace responses + if (msg.command === "stackTrace") { + const frames = (msg.body as Record)?.stackFrames as Array<{ id: number }> | undefined; + if (frames && frames.length > 0) { + this.lastFrameId = frames[0].id; + } + } + // Handle variables responses (Variables pane) if (msg.command === "variables" && msg.body.variables) { + // Cache evaluateNames for setVariable rewriting + const varRef = msg.request_seq !== undefined + ? this.pendingVarRequests.get(msg.request_seq) + : undefined; + if (varRef !== undefined) { + this.pendingVarRequests.delete(msg.request_seq!); + for (const v of msg.body.variables) { + if (v.evaluateName) { + this.varEvalNameCache.set(this.cacheKey(varRef, v.name), v.evaluateName); + } + } + } this.transformVariables(msg.body.variables); } From f2094f53e1814f4c6d677e87ddcbc67fbbc3f0fd Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 14 Apr 2026 11:01:57 -0400 Subject: [PATCH 17/17] =?UTF-8?q?chore:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20remove=20dead=20IPC=20code,=20fix=20leaks,=20clean?= =?UTF-8?q?=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove duplicate JSDoc comment on varEvalNameCache. 2. Remove debug logging: per-request DAP log in tracker, console.log in debug-config-builder and debug-config-provider. 3. Clear varEvalNameCache and pendingVarRequests on DAP "continued" event (when execution resumes, cached references become stale). 4. Remove dead IPC infrastructure that was built for Option D (pipe when running) but never used — forcing only works via GDB when stopped at a breakpoint: - Delete iec_command_server.hpp (C++ socket server) - Delete repl-client.ts (TypeScript socket client) - Remove --cmd-pipe arg parsing from generated main() - Remove cmdPipePath from DebugBuildState and DebugBuildInfo - Remove __cmdPipePath from debug configurations Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend/repl-main-gen.ts | 15 - src/runtime/repl/iec_command_server.hpp | 272 ------------------ vscode-extension/client/src/commands.ts | 12 +- .../client/src/debug-adapter-tracker.ts | 18 +- .../client/src/debug-config-builder.ts | 8 - .../client/src/debug-config-provider.ts | 4 +- vscode-extension/client/src/repl-client.ts | 200 ------------- 7 files changed, 10 insertions(+), 519 deletions(-) delete mode 100644 src/runtime/repl/iec_command_server.hpp delete mode 100644 vscode-extension/client/src/repl-client.ts diff --git a/src/backend/repl-main-gen.ts b/src/backend/repl-main-gen.ts index d0054c6..2c28942 100644 --- a/src/backend/repl-main-gen.ts +++ b/src/backend/repl-main-gen.ts @@ -120,8 +120,6 @@ export function generateReplMain( lines.push(`#include "${options.headerFileName}"`); lines.push('#include "iec_repl.hpp"'); lines.push('#include "iec_cyclic.hpp"'); - lines.push('#include "iec_command_server.hpp"'); - lines.push("#include "); lines.push(""); lines.push(`using namespace ${ns};`); lines.push("using strucpp::VarTypeTag;"); @@ -279,24 +277,11 @@ function emitProgramDescriptorsAndMain( lines.push("int main(int argc, char* argv[]) {"); lines.push(" bool cyclic = false;"); lines.push(" bool print_vars = false;"); - lines.push(" std::string cmd_pipe_path;"); lines.push(" for (int i = 1; i < argc; ++i) {"); lines.push(' if (std::string(argv[i]) == "--cyclic") cyclic = true;'); lines.push( ' if (std::string(argv[i]) == "--print-vars") print_vars = true;', ); - lines.push( - ' if (std::string(argv[i]) == "--cmd-pipe" && i + 1 < argc) cmd_pipe_path = argv[++i];', - ); - lines.push(" }"); - lines.push(""); - lines.push(" // Start IPC command server if pipe path provided"); - lines.push(" std::unique_ptr cmd_server;"); - lines.push(" if (!cmd_pipe_path.empty()) {"); - lines.push( - ` cmd_server = std::make_unique(cmd_pipe_path, programs, ${programs.length});`, - ); - lines.push(" cmd_server->start();"); lines.push(" }"); lines.push(""); lines.push(" if (cyclic) {"); diff --git a/src/runtime/repl/iec_command_server.hpp b/src/runtime/repl/iec_command_server.hpp deleted file mode 100644 index b268d29..0000000 --- a/src/runtime/repl/iec_command_server.hpp +++ /dev/null @@ -1,272 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2025 Autonomy / OpenPLC Project -/** - * STruC++ IPC Command Server - * - * Listens on a platform-specific pipe (Unix domain socket on Linux/macOS, - * Named Pipe on Windows) for text commands from the VSCode extension. - * Commands are processed by process_command() from iec_repl.hpp — the same - * code path used by the interactive REPL. - * - * Protocol: newline-delimited text. Each line is a REPL command - * (e.g., "force instance0.STATE 2"). Response is a single line - * starting with "OK:" or "ERR:". - */ - -#pragma once - -#include "iec_repl.hpp" -#include -#include -#include -#include -#include - -// ============================================================================= -// Platform-specific includes -// ============================================================================= - -#if defined(_WIN32) -# include -#else -# include -# include -# include -# include -# include -#endif - -namespace strucpp { - -class CommandServer { -public: - CommandServer(const std::string& pipe_path, - ProgramDescriptor* programs, size_t program_count) - : pipe_path_(pipe_path) - , programs_(programs) - , program_count_(program_count) - {} - - ~CommandServer() { stop(); } - - // Non-copyable - CommandServer(const CommandServer&) = delete; - CommandServer& operator=(const CommandServer&) = delete; - - void start() { - if (running_.load()) return; - running_.store(true); - listener_thread_ = std::thread([this]() { listener_loop(); }); - } - - void stop() { - if (!running_.exchange(false)) return; -#if defined(_WIN32) - // Wake up ConnectNamedPipe by connecting briefly - HANDLE h = CreateFileA(pipe_path_.c_str(), GENERIC_READ, 0, - nullptr, OPEN_EXISTING, 0, nullptr); - if (h != INVALID_HANDLE_VALUE) CloseHandle(h); -#else - if (server_fd_ >= 0) { - shutdown(server_fd_, SHUT_RDWR); - close(server_fd_); - server_fd_ = -1; - } - unlink(pipe_path_.c_str()); -#endif - if (listener_thread_.joinable()) { - listener_thread_.join(); - } - } - -private: - std::string pipe_path_; - ProgramDescriptor* programs_; - size_t program_count_; - std::atomic running_{false}; - std::thread listener_thread_; - -#if !defined(_WIN32) - int server_fd_{-1}; -#endif - - // ========================================================================= - // Platform: Linux / macOS (Unix domain socket) - // ========================================================================= -#if !defined(_WIN32) - - void listener_loop() { - // Remove stale socket from previous crash - unlink(pipe_path_.c_str()); - - server_fd_ = socket(AF_UNIX, SOCK_STREAM, 0); - if (server_fd_ < 0) { - fprintf(stderr, "[cmd-server] socket() failed: %s\n", strerror(errno)); - return; - } - - struct sockaddr_un addr{}; - addr.sun_family = AF_UNIX; - strncpy(addr.sun_path, pipe_path_.c_str(), sizeof(addr.sun_path) - 1); - - if (bind(server_fd_, reinterpret_cast(&addr), sizeof(addr)) < 0) { - fprintf(stderr, "[cmd-server] bind(%s) failed: %s\n", - pipe_path_.c_str(), strerror(errno)); - close(server_fd_); - server_fd_ = -1; - return; - } - - if (listen(server_fd_, 1) < 0) { - fprintf(stderr, "[cmd-server] listen() failed: %s\n", strerror(errno)); - close(server_fd_); - server_fd_ = -1; - return; - } - - // Set accept timeout so we can check running_ periodically - struct timeval tv{}; - tv.tv_sec = 0; - tv.tv_usec = 500000; // 500ms - setsockopt(server_fd_, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - - fprintf(stderr, "[cmd-server] Listening on %s\n", pipe_path_.c_str()); - - while (running_.load()) { - int client_fd = accept(server_fd_, nullptr, nullptr); - if (client_fd < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) continue; // timeout - if (!running_.load()) break; // shutting down - fprintf(stderr, "[cmd-server] accept() failed: %s\n", strerror(errno)); - continue; - } - handle_client(client_fd); - close(client_fd); - } - - if (server_fd_ >= 0) { - close(server_fd_); - server_fd_ = -1; - } - unlink(pipe_path_.c_str()); - } - - void handle_client(int client_fd) { - // Set read timeout so a dead client doesn't block the server forever - struct timeval tv{}; - tv.tv_sec = 1; - tv.tv_usec = 0; - setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - - std::string buffer; - char chunk[1024]; - - while (running_.load()) { - ssize_t n = read(client_fd, chunk, sizeof(chunk)); - if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) continue; // read timeout, check running_ - break; // real error - } - if (n == 0) break; // client disconnected - - buffer.append(chunk, static_cast(n)); - - // Process complete lines - size_t pos; - while ((pos = buffer.find('\n')) != std::string::npos) { - std::string line = buffer.substr(0, pos); - buffer.erase(0, pos + 1); - - if (line.empty()) continue; - - std::string response = process_command(line, programs_, program_count_); - response += "\n"; - - // Write full response - size_t written = 0; - while (written < response.size()) { - ssize_t w = write(client_fd, response.data() + written, - response.size() - written); - if (w <= 0) return; // write error - written += static_cast(w); - } - } - } - } - - // ========================================================================= - // Platform: Windows (Named Pipe) - // ========================================================================= -#else - - void listener_loop() { - fprintf(stderr, "[cmd-server] Listening on %s\n", pipe_path_.c_str()); - - while (running_.load()) { - HANDLE pipe = CreateNamedPipeA( - pipe_path_.c_str(), - PIPE_ACCESS_DUPLEX, - PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, - 1, // max instances - 4096, // output buffer - 4096, // input buffer - 500, // default timeout ms (for ConnectNamedPipe) - nullptr // security attributes - ); - - if (pipe == INVALID_HANDLE_VALUE) { - fprintf(stderr, "[cmd-server] CreateNamedPipe failed: %lu\n", GetLastError()); - return; - } - - // Wait for client connection (blocks, but stop() wakes it) - BOOL connected = ConnectNamedPipe(pipe, nullptr) - ? TRUE - : (GetLastError() == ERROR_PIPE_CONNECTED ? TRUE : FALSE); - - if (!running_.load()) { - CloseHandle(pipe); - break; - } - - if (connected) { - handle_client_win(pipe); - } - - DisconnectNamedPipe(pipe); - CloseHandle(pipe); - } - } - - void handle_client_win(HANDLE pipe) { - std::string buffer; - char chunk[1024]; - - while (running_.load()) { - DWORD bytesRead = 0; - BOOL ok = ReadFile(pipe, chunk, sizeof(chunk), &bytesRead, nullptr); - if (!ok || bytesRead == 0) break; - - buffer.append(chunk, bytesRead); - - size_t pos; - while ((pos = buffer.find('\n')) != std::string::npos) { - std::string line = buffer.substr(0, pos); - buffer.erase(0, pos + 1); - - if (line.empty()) continue; - - std::string response = process_command(line, programs_, program_count_); - response += "\n"; - - DWORD written = 0; - WriteFile(pipe, response.data(), - static_cast(response.size()), &written, nullptr); - } - } - } - -#endif // _WIN32 -}; - -} // namespace strucpp diff --git a/vscode-extension/client/src/commands.ts b/vscode-extension/client/src/commands.ts index 4a72335..707794a 100644 --- a/vscode-extension/client/src/commands.ts +++ b/vscode-extension/client/src/commands.ts @@ -6,7 +6,6 @@ import * as vscode from "vscode"; import * as path from "node:path"; -import * as os from "node:os"; import * as fs from "node:fs"; import { execFile } from "node:child_process"; import type { LanguageClient } from "vscode-languageclient/node.js"; @@ -34,8 +33,6 @@ export interface DebugBuildState { outputDir: string; lineMap: Array<{ stLine: number; cppStart: number; cppEnd: number }>; sourceUri: string; - /** Path for the IPC command pipe (for variable forcing) */ - cmdPipePath: string; } let _lastDebugBuild: DebugBuildState | undefined; @@ -435,18 +432,11 @@ export async function debugBuildCommand( showWarnings(response); outputChannel.appendLine(`Debug binary built: ${binaryPath}`); - // Generate unique pipe path for IPC command server - const cmdPipePath = process.platform === "win32" - ? `\\\\.\\pipe\\strucpp-cmd-${process.pid}-${Date.now()}` - : path.join(os.tmpdir(), `strucpp-cmd-${process.pid}-${Date.now()}.sock`); - outputChannel.appendLine(`[repl-client] Generated cmd pipe path: ${cmdPipePath}`); - _lastDebugBuild = { binaryPath, outputDir, lineMap: response.lineMap, sourceUri: uri, - cmdPipePath, }; return _lastDebugBuild; @@ -490,7 +480,7 @@ async function launchDebugSession(state: DebugBuildState): Promise { const miMode: "lldb" | "gdb" = isMac ? "lldb" : "gdb"; const setupCommands = buildSetupCommands(miMode); const debugConfig = buildDebugConfig( - { binaryPath: state.binaryPath, outputDir: state.outputDir, cmdPipePath: state.cmdPipePath }, + { binaryPath: state.binaryPath, outputDir: state.outputDir }, debugType, miMode, setupCommands, diff --git a/vscode-extension/client/src/debug-adapter-tracker.ts b/vscode-extension/client/src/debug-adapter-tracker.ts index 37aadc3..5aee12e 100644 --- a/vscode-extension/client/src/debug-adapter-tracker.ts +++ b/vscode-extension/client/src/debug-adapter-tracker.ts @@ -39,10 +39,6 @@ export class StrucppDebugTrackerFactory } class StrucppDebugTracker implements vscode.DebugAdapterTracker { - /** - * Cache: maps (variablesReference, name) → evaluateName. - * Populated from variables responses, used to resolve setVariable requests. - */ /** * Cache: maps (variablesReference, name) → evaluateName. * Populated from variables responses, used to resolve setVariable requests. @@ -69,12 +65,6 @@ class StrucppDebugTracker implements vscode.DebugAdapterTracker { arguments?: Record; }; - // Log all requests for debugging - if (msg.type === "request") { - const args = msg.arguments ? JSON.stringify(msg.arguments).substring(0, 200) : "{}"; - log.appendLine(`[tracker] → ${msg.command} (seq=${msg.seq}) args=${args}`); - } - // Track variables requests so we can cache evaluateNames from responses if (msg.type === "request" && msg.command === "variables" && msg.seq !== undefined) { const varRef = msg.arguments?.variablesReference as number | undefined; @@ -163,6 +153,14 @@ class StrucppDebugTracker implements vscode.DebugAdapterTracker { }; }; + // Clear caches when the target resumes — cached references become stale. + const evt = message as { type?: string; event?: string }; + if (evt.type === "event" && evt.event === "continued") { + this.varEvalNameCache.clear(); + this.pendingVarRequests.clear(); + return; + } + if (msg.type !== "response" || !msg.body) return; // Cache the top frame ID from stackTrace responses diff --git a/vscode-extension/client/src/debug-config-builder.ts b/vscode-extension/client/src/debug-config-builder.ts index 6cf5251..1d74615 100644 --- a/vscode-extension/client/src/debug-config-builder.ts +++ b/vscode-extension/client/src/debug-config-builder.ts @@ -14,8 +14,6 @@ import type * as vscode from "vscode"; export interface DebugBuildInfo { binaryPath: string; outputDir: string; - /** Path for the IPC command pipe (Unix socket or Win32 named pipe) */ - cmdPipePath?: string; } /** Optional user overrides from launch.json or debug config provider. */ @@ -140,10 +138,6 @@ export function buildDebugConfig( ): vscode.DebugConfiguration { const name = overrides?.name || "Debug ST Program"; const args = ["--cyclic"]; - if (build.cmdPipePath) { - args.push("--cmd-pipe", build.cmdPipePath); - } - console.log(`[strucpp:debug-config] buildDebugConfig: cmdPipePath=${build.cmdPipePath ?? "NONE"}, args=${JSON.stringify(args)}`); if (debugType === "lldb") { return { @@ -154,7 +148,6 @@ export function buildDebugConfig( args, cwd: build.outputDir, __strucpp: true, - __cmdPipePath: build.cmdPipePath, initCommands: getLLDBInitCommands(), ...(overrides?.env ? { env: overrides.env } : {}), ...(overrides?.stopOnEntry ? { stopOnEntry: true } : {}), @@ -169,7 +162,6 @@ export function buildDebugConfig( args, cwd: build.outputDir, __strucpp: true, - __cmdPipePath: build.cmdPipePath, MIMode: miMode, setupCommands, ...(overrides?.env diff --git a/vscode-extension/client/src/debug-config-provider.ts b/vscode-extension/client/src/debug-config-provider.ts index 74edc6c..2d38701 100644 --- a/vscode-extension/client/src/debug-config-provider.ts +++ b/vscode-extension/client/src/debug-config-provider.ts @@ -62,8 +62,6 @@ export class StrucppDebugConfigProvider return undefined; } - console.log(`[strucpp:debug-provider] debugState.cmdPipePath=${debugState.cmdPipePath}`); - const isMac = process.platform === "darwin"; const hasCodeLLDB = vscode.extensions.getExtension("vadimcn.vscode-lldb") != null; const debugType = isMac && hasCodeLLDB ? "lldb" : "cppdbg"; @@ -72,7 +70,7 @@ export class StrucppDebugConfigProvider const setupCommands = buildSetupCommands(miMode, prettyPrinterPath); return buildDebugConfig( - { binaryPath: debugState.binaryPath, outputDir: debugState.outputDir, cmdPipePath: debugState.cmdPipePath }, + { binaryPath: debugState.binaryPath, outputDir: debugState.outputDir }, debugType, miMode, setupCommands, diff --git a/vscode-extension/client/src/repl-client.ts b/vscode-extension/client/src/repl-client.ts deleted file mode 100644 index edd5a3b..0000000 --- a/vscode-extension/client/src/repl-client.ts +++ /dev/null @@ -1,200 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2025 Autonomy / OpenPLC Project -/** - * ReplClient — IPC client for the STruC++ command server. - * - * Connects to the debug binary's command pipe (Unix domain socket on - * Linux/macOS, Named Pipe on Windows) and sends REPL commands as plain text. - * The protocol is newline-delimited: one command per line, one response per line. - * - * Commands use the same format as the interactive REPL: - * "force instance0.STATE 2" → "OK: instance0.STATE FORCED = 2" - * "get instance0.STATE" → "OK: instance0.STATE : INT = 2 [FORCED]" - * "unforce instance0.STATE" → "OK: instance0.STATE unforced. Value: 0" - */ - -import * as net from "node:net"; - -/** Result from a command that may succeed or fail. */ -export interface CommandResult { - ok: boolean; - message: string; -} - -export class ReplClient { - private socket: net.Socket | null = null; - private connected = false; - private responseBuffer = ""; - private pendingResolve: ((line: string) => void) | null = null; - private pendingReject: ((err: Error) => void) | null = null; - - /** - * Connect to the command server pipe. - * Retries with exponential backoff (up to ~3s total) to allow the - * binary time to start and create the pipe. - */ - async connect(pipePath: string): Promise { - const maxRetries = 8; - const baseDelay = 250; // ms - const fs = await import("node:fs"); - - for (let attempt = 0; attempt < maxRetries; attempt++) { - const exists = fs.existsSync(pipePath); - console.log(`[repl-client] connect attempt ${attempt + 1}/${maxRetries}, socket exists: ${exists}`); - if (exists) { - try { - await this.tryConnect(pipePath); - return; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.log(`[repl-client] attempt ${attempt + 1} failed: ${msg}`); - } - } - if (attempt < maxRetries - 1) { - await new Promise((r) => setTimeout(r, baseDelay * Math.pow(2, attempt))); - } - } - throw new Error(`Failed to connect to command server at ${pipePath}`); - } - - private tryConnect(pipePath: string): Promise { - return new Promise((resolve, reject) => { - let settled = false; - - const sock = net.createConnection({ path: pipePath }, () => { - settled = true; - // Disable the connection timeout now that we're connected - sock.setTimeout(0); - this.socket = sock; - this.connected = true; - this.responseBuffer = ""; - resolve(); - }); - - sock.on("data", (data: Buffer) => { - this.responseBuffer += data.toString(); - this.drainBuffer(); - }); - - sock.on("close", () => { - this.connected = false; - this.socket = null; - if (this.pendingReject) { - this.pendingReject(new Error("Connection closed")); - this.pendingResolve = null; - this.pendingReject = null; - } - }); - - sock.on("error", (err: Error) => { - this.connected = false; - this.socket = null; - if (this.pendingReject) { - this.pendingReject(err); - this.pendingResolve = null; - this.pendingReject = null; - } - if (!settled) { - settled = true; - reject(err); - } - }); - - // Timeout only for the initial connection attempt - sock.setTimeout(5000, () => { - if (!settled) { - settled = true; - sock.destroy(); - reject(new Error("Connection timeout")); - } - }); - }); - } - - /** Process buffered data and resolve pending command if a complete line arrived. */ - private drainBuffer(): void { - const nlIndex = this.responseBuffer.indexOf("\n"); - if (nlIndex >= 0 && this.pendingResolve) { - const line = this.responseBuffer.substring(0, nlIndex); - this.responseBuffer = this.responseBuffer.substring(nlIndex + 1); - const resolve = this.pendingResolve; - this.pendingResolve = null; - this.pendingReject = null; - resolve(line); - } - } - - disconnect(): void { - if (this.socket) { - this.socket.destroy(); - this.socket = null; - } - this.connected = false; - this.responseBuffer = ""; - if (this.pendingReject) { - this.pendingReject(new Error("Disconnected")); - this.pendingResolve = null; - this.pendingReject = null; - } - } - - isConnected(): boolean { - return this.connected; - } - - /** - * Send a REPL command and wait for the response line. - * @param command Full REPL command (e.g., "force instance0.STATE 2") - * @returns The response line (e.g., "OK: instance0.STATE FORCED = 2") - */ - async sendCommand(command: string): Promise { - if (!this.socket || !this.connected) { - throw new Error("Not connected to command server"); - } - - if (this.pendingResolve) { - throw new Error("A command is already in progress"); - } - - return new Promise((resolve, reject) => { - this.pendingResolve = resolve; - this.pendingReject = reject; - - // Timeout after 5 seconds - const timeout = setTimeout(() => { - if (this.pendingReject) { - this.pendingReject(new Error("Command timed out")); - this.pendingResolve = null; - this.pendingReject = null; - } - }, 5000); - - // Clear timeout when resolved - const originalResolve = this.pendingResolve; - this.pendingResolve = (line: string) => { - clearTimeout(timeout); - originalResolve(line); - }; - - this.socket!.write(command + "\n"); - - // Check if response already in buffer - this.drainBuffer(); - }); - } - - /** - * Parse a response line into a CommandResult. - * "OK: ..." → {ok: true, message: "..."} - * "ERR: ..." → {ok: false, message: "..."} - */ - static parseResponse(response: string): CommandResult { - if (response.startsWith("OK:")) { - return { ok: true, message: response.substring(3).trimStart() }; - } - if (response.startsWith("ERR:")) { - return { ok: false, message: response.substring(4).trimStart() }; - } - return { ok: false, message: response }; - } -}