From 12930e857889859df71f813e87e82d738f168562 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 4 Aug 2026 19:50:02 -0400 Subject: [PATCH 1/4] fix(runtime-v4): report a switch in STOP as a warning, not a failed upload Uploading with the hardware mode switch in STOP ended with: Compilation completed successfully (exit code: 0). Failed to start PLC: START:ERROR_SWITCH_STOP Failed to upload to runtime. Stopping compilation process. All three lines after the first are wrong. The program did reach the device and compiled there; the runtime simply declined to start it, which is what a mode switch in STOP is for. Leaving the switch there while uploading is a normal thing to do, so the message should not send anyone looking for a problem. startPlcAfterBuild now recognises ERROR_SWITCH_STOP as its own outcome and explains it as a warning: "Program uploaded. The PLC was not started because the mode switch is in STOP -- move it to RUN to start." It is not retried either; nothing changes until a human moves the switch, so the 5 s BUSY loop had no business spinning on it. deployRuntimeProgram maps that to UPLOADED_NOT_STARTED, and both platform adapters treat it as a successful upload. Whether an outcome means "the program reached the device" now lives in one shared predicate, deployReachedDevice(), rather than being re-derived as `outcome === 'STARTED'` in each adapter -- the editor had it in two places and web in a third, which is how they would drift. Not addressed here, but noticed: START_TIMEOUT still maps to a failed upload, so a runtime that stays BUSY past the deadline reports "Failed to upload to runtime" after logging a warning that says otherwise. Same shape of bug, different trigger. --- .../compiler/editor-compiler-platform-port.ts | 14 ++++++++++--- .../__tests__/start-plc-after-build.test.ts | 19 +++++++++++++++++ .../shared/library/deploy-runtime-program.ts | 21 +++++++++++++++++++ .../shared/library/start-plc-after-build.ts | 19 ++++++++++++++++- 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index 8b689d18d..8c78488b2 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -24,7 +24,7 @@ * `middleware/adapters/web/`. */ -import { deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' +import { deployReachedDevice, deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' import { probeRuntimeVersion } from '@root/backend/shared/library/probe-runtime-version' import { fromSchemaShape, @@ -387,7 +387,11 @@ export function createEditorCompilerPlatformPort( startIntervalMs: context.startIntervalMs, }) - return { ok: deployOutcome === 'STARTED' } + // 'STARTED' is not the only success: a runtime that refused to start + // because its hardware mode switch reads STOP has still taken the + // program. deployReachedDevice keeps that judgement in one place, shared + // with the web adapter. + return { ok: deployReachedDevice(deployOutcome) } } catch (error) { const message = error instanceof Error ? error.message : String(error) log(`Runtime v4 upload failed: ${message}`, 'error') @@ -486,7 +490,11 @@ export function createEditorCompilerPlatformPort( startTimeoutMs: context.startTimeoutMs, startIntervalMs: context.startIntervalMs, }) - return { ok: deployOutcome === 'STARTED' } + // 'STARTED' is not the only success: a runtime that refused to start + // because its hardware mode switch reads STOP has still taken the + // program. deployReachedDevice keeps that judgement in one place, shared + // with the web adapter. + return { ok: deployReachedDevice(deployOutcome) } } catch (error) { const message = error instanceof Error ? error.message : String(error) log(`Runtime v3 upload failed: ${message}`, 'error') diff --git a/src/backend/shared/library/__tests__/start-plc-after-build.test.ts b/src/backend/shared/library/__tests__/start-plc-after-build.test.ts index 15d97ab21..6829b160a 100644 --- a/src/backend/shared/library/__tests__/start-plc-after-build.test.ts +++ b/src/backend/shared/library/__tests__/start-plc-after-build.test.ts @@ -54,6 +54,25 @@ describe('startPlcAfterBuild', () => { expect(calls()).toBe(3) }) + it('treats a mode switch in STOP as a warning, not a failure', async () => { + // The runtime refuses to start while a hardware switch reads STOP. The + // program is on the device, so this must NOT read as a failed upload -- that + // sent users hunting for a problem that did not exist. + const { fetch, calls } = scriptedFetch(['START:ERROR_SWITCH_STOP']) + const logs: Array<{ level: string; message: string }> = [] + const outcome = await startPlcAfterBuild({ + fetchStart: fetch, + onLog: (level, message) => logs.push({ level, message }), + pollIntervalMs: 1, + }) + expect(outcome).toBe('SWITCH_IN_STOP') + // Not retried: nothing changes until a human moves the switch. + expect(calls()).toBe(1) + expect(logs[logs.length - 1].level).toBe('warning') + expect(logs[logs.length - 1].message).toMatch(/uploaded/i) + expect(logs[logs.length - 1].message).toMatch(/switch is in STOP/i) + }) + it('bails with FAILED on a non-BUSY error reply', async () => { const { fetch, calls } = scriptedFetch(['ERROR:INVALID_PROGRAM']) const logs: Array<{ level: string; message: string }> = [] diff --git a/src/backend/shared/library/deploy-runtime-program.ts b/src/backend/shared/library/deploy-runtime-program.ts index 783ae5676..2ae037970 100644 --- a/src/backend/shared/library/deploy-runtime-program.ts +++ b/src/backend/shared/library/deploy-runtime-program.ts @@ -23,6 +23,7 @@ import { startPlcAfterBuild, type StartPlcAfterBuildOptions } from './start-plc- export type DeployRuntimeProgramOutcome = | 'STARTED' + | 'UPLOADED_NOT_STARTED' | 'UPLOAD_FAILED' | 'BUILD_FAILED' | 'BUILD_TIMEOUT' @@ -30,6 +31,23 @@ export type DeployRuntimeProgramOutcome = | 'START_FAILED' | 'START_TIMEOUT' +/** + * Did the program reach the device? + * + * Distinct from "did it start", because those are different questions and the + * upload step must only fail for the first one. A runtime that refuses to start + * while its hardware mode switch reads STOP has still taken the program, and + * reporting that as a failed upload sends the user looking for a problem that + * does not exist. + * + * Shared rather than re-derived per platform: the editor and web adapters both + * turn this outcome into `UploadResult.ok`, and they must not drift on which + * outcomes count. + */ +export function deployReachedDevice(outcome: DeployRuntimeProgramOutcome): boolean { + return outcome === 'STARTED' || outcome === 'UPLOADED_NOT_STARTED' +} + export type DeployRuntimeProgramLogLevel = 'info' | 'error' | 'warning' | 'debug' export interface DeployRuntimeProgramOptions { @@ -109,6 +127,9 @@ export async function deployRuntimeProgram(opts: DeployRuntimeProgramOptions): P pollIntervalMs: opts.startIntervalMs, }) if (startOutcome === 'STARTED') return 'STARTED' + // Refused by the hardware mode switch: uploaded, deliberately not running. + // startPlcAfterBuild has already explained it as a warning. + if (startOutcome === 'SWITCH_IN_STOP') return 'UPLOADED_NOT_STARTED' if (startOutcome === 'TIMEOUT') return 'START_TIMEOUT' return 'START_FAILED' } diff --git a/src/backend/shared/library/start-plc-after-build.ts b/src/backend/shared/library/start-plc-after-build.ts index 583b60a51..fda248154 100644 --- a/src/backend/shared/library/start-plc-after-build.ts +++ b/src/backend/shared/library/start-plc-after-build.ts @@ -21,12 +21,17 @@ * - 150-ms interval between attempts. * - `START:OK` or `ALREADY_RUNNING` in the runtime's reply → SUCCESS. * - `BUSY` in the reply → keep retrying. + * - `ERROR_SWITCH_STOP` → SWITCH_IN_STOP: not an error. The runtime + * refuses to start while a hardware mode switch reads STOP, and + * leaving the switch there is a normal thing for someone to do + * while uploading. The upload succeeded; only the start was + * declined, and the user decides when to allow it. * - Any other reply → terminal failure (e.g. invalid program). * - Network error on the fetch → terminal failure. * - Deadline elapsed → TIMEOUT (a warning, not a fatal error). */ -export type StartPlcAfterBuildOutcome = 'STARTED' | 'FAILED' | 'TIMEOUT' +export type StartPlcAfterBuildOutcome = 'STARTED' | 'FAILED' | 'TIMEOUT' | 'SWITCH_IN_STOP' export type StartPlcAfterBuildLogLevel = 'info' | 'error' | 'warning' @@ -74,6 +79,18 @@ export async function startPlcAfterBuild(opts: StartPlcAfterBuildOptions): Promi return 'STARTED' } + // A hardware mode switch in STOP is a refusal, not a failure: the + // program is on the device, and the runtime is doing exactly what it + // should by declining to run it until the switch says so. Retrying + // would be wrong too — nothing changes until someone moves it. + if (status.includes('ERROR_SWITCH_STOP')) { + opts.onLog( + 'warning', + 'Program uploaded. The PLC was not started because the mode switch is in STOP — move it to RUN to start.', + ) + return 'SWITCH_IN_STOP' + } + // Only BUSY is retryable — everything else is a real error // from the runtime (invalid program, compile error, …). if (!status.includes('BUSY')) { From a348960f9921d70278db366a5b7df366dfcc905f Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 4 Aug 2026 22:17:10 -0400 Subject: [PATCH 2/4] fix(runtime-v4): block run/stop while the runtime is mid-transition TRANSITIONING means a start or stop is in flight: the runtime answers COMMAND:BUSY to everything except PING and STATUS, and the state it will settle on is not decided yet. Clicking the sidebar button then could only produce a confusing error -- and the icon is ambiguous anyway, since it is drawn from a state that is about to change. The button now goes inert until the runtime lands, with the tooltip saying "PLC is changing state..." instead of offering Start or Stop. handlePlcControl gets the same guard. Status is polled every 2 s, so a render can be up to that stale; the guard closes the window where the button looks live because the poll has not caught up yet, and covers any caller that is not the click. The condition lives in one derived flag rather than being repeated in the disabled prop, the className and the tooltip. --- .../workspace-activity-bar/default.tsx | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index a11b96cda..acdab609a 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -425,6 +425,11 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const handlePlcControl = useCallback(async (): Promise => { if (!jwtToken || connectionStatus !== 'connected') return + // Mid-transition the runtime refuses everything but PING and STATUS with + // COMMAND:BUSY, so a start or stop here can only produce a confusing error. + // The button is disabled for this too; guarding the handler as well covers + // any other caller and the gap between a stale render and the next poll. + if (plcStatus === 'TRANSITIONING') return try { if (plcStatus === 'RUNNING') { @@ -460,6 +465,18 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } }, [runtime, jwtToken, connectionStatus, plcStatus, addLog]) + /** + * Run/stop is blocked while the runtime is mid-transition. + * + * TRANSITIONING means a start or stop is in flight: the runtime answers + * COMMAND:BUSY to everything except PING and STATUS, and the state it will + * settle on is not decided yet. Clicking then cannot do what the icon says -- + * the icon itself is ambiguous, since it is drawn from a state that is about to + * change -- so the button goes inert until the runtime lands. + */ + const isPlcTransitioning = plcStatus === 'TRANSITIONING' + const isPlcControlBlocked = connectionStatus !== 'connected' || isPlcTransitioning + // --------------------------------------------------------------------------- // Simulator control (Start/Stop simulator + auto-debug) // --------------------------------------------------------------------------- @@ -904,20 +921,22 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa : 'Start Simulator' : connectionStatus !== 'connected' ? 'Connect to runtime first' - : plcStatus === 'RUNNING' - ? 'Stop PLC' - : 'Start PLC' + : isPlcTransitioning + ? 'PLC is changing state...' + : plcStatus === 'RUNNING' + ? 'Stop PLC' + : 'Start PLC' } > void handleSimulatorControl() : () => void handlePlcControl()} - disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : connectionStatus !== 'connected'} + disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : isPlcControlBlocked} className={cn( isSimulatorBoard ? isCompiling || isDebuggerProcessing ? disabledButtonClass : '' - : connectionStatus !== 'connected' + : isPlcControlBlocked ? disabledButtonClass : '', )} From 82bcb6530b3fecb008f453dff0d050739295f7a9 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 4 Aug 2026 22:23:54 -0400 Subject: [PATCH 3/4] fix(runtime-v4): tell the user when the mode switch refuses a start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Start with the switch in STOP did nothing at all — no dialog, not even a log line. The button correctly failed to start the PLC and then said nothing about why. The refusal arrives as a SUCCESSFUL request carrying a refusal status: the HTTP call reached the runtime, which answered START:ERROR_SWITCH_STOP. handlePlcControl only inspected result.success, so the refusal was swallowed whole, and the follow-up getStatus() quietly re-rendered the button as Start again. It now reads the status. A switch refusal opens a dialog — "The mode switch on the device is in STOP, so the runtime refused to start the PLC. Move the switch to RUN and try again." — rather than a console line, because the user just asked for this explicitly, is watching the button, and is the only one who can resolve it. Any other unexpected status is logged as an error instead of vanishing. Deliberately different from the upload path, where the same refusal is a warning in the log: there the start is an automatic follow-on step and interrupting a build with a modal would be wrong. --- .../workspace-activity-bar/default.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index acdab609a..76b6b5816 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -452,6 +452,33 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa }) return } + + // A refusal arrives as a successful request carrying a refusal status — + // the HTTP call worked, the runtime just declined. Checking only + // `success` therefore swallowed it: pressing Start with the mode switch + // in STOP did nothing at all, with no dialog and not even a log line. + // + // The switch case gets a dialog rather than a console entry because the + // user just asked for this explicitly and is watching the button; only + // they can resolve it, by moving the switch. + const status = result.status ?? '' + if (status.includes('ERROR_SWITCH_STOP')) { + void showDebuggerMessage( + 'warning', + 'PLC Not Started', + 'The mode switch on the device is in STOP, so the runtime refused to start the PLC. Move the switch to RUN and try again.', + ['OK'], + ) + return + } + if (!status.includes('START:OK') && !status.includes('ALREADY_RUNNING')) { + addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `Failed to start PLC: ${status || 'unknown response'}`, + }) + return + } } const statusResult = await runtime.getStatus() From 22a1c3de06766d96a5e24ec8435dbbaea867d5d6 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 4 Aug 2026 22:28:44 -0400 Subject: [PATCH 4/4] Revert "fix(runtime-v4): tell the user when the mode switch refuses a start" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 82bcb6530. The feature is not missing — it lives on feature/runtime-run-stop-state, which is the branch this was being tested on, and it is better than what I wrote there. That branch carries refusedBySwitch as a first-class field in the shared debug types, produced by BOTH transports (Modbus FC 0x4b status 0x86 and REST ERROR_SWITCH_STOP) as well as the simulator, and surfaces it through warnSwitchInStop(boardTarget, switchLabel), which names the switch using the label the VPP declares. It has tests. My version string-matched the REST status inside the component, covered neither Modbus nor the simulator, and hardcoded the wording. Keeping it would duplicate working code in a worse form and collide with that branch on merge. --- .../workspace-activity-bar/default.tsx | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 76b6b5816..acdab609a 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -452,33 +452,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa }) return } - - // A refusal arrives as a successful request carrying a refusal status — - // the HTTP call worked, the runtime just declined. Checking only - // `success` therefore swallowed it: pressing Start with the mode switch - // in STOP did nothing at all, with no dialog and not even a log line. - // - // The switch case gets a dialog rather than a console entry because the - // user just asked for this explicitly and is watching the button; only - // they can resolve it, by moving the switch. - const status = result.status ?? '' - if (status.includes('ERROR_SWITCH_STOP')) { - void showDebuggerMessage( - 'warning', - 'PLC Not Started', - 'The mode switch on the device is in STOP, so the runtime refused to start the PLC. Move the switch to RUN and try again.', - ['OK'], - ) - return - } - if (!status.includes('START:OK') && !status.includes('ALREADY_RUNNING')) { - addLog({ - id: crypto.randomUUID(), - level: 'error', - message: `Failed to start PLC: ${status || 'unknown response'}`, - }) - return - } } const statusResult = await runtime.getStatus()