diff --git a/mobile/rpc-foundation/goldens/components-target-local.json b/mobile/rpc-foundation/goldens/components-target-local.json index 52042bc33d8..584ee706c82 100644 --- a/mobile/rpc-foundation/goldens/components-target-local.json +++ b/mobile/rpc-foundation/goldens/components-target-local.json @@ -4,7 +4,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "values": { - "1e4520fe6576": { + "5779e5e854f7": { "detected": { "$rpc": "null" }, @@ -13,19 +13,7 @@ "error": { "$rpc": "null" }, - "requiresConnection": false, - "status": { - "$rpc": "null" - } - } - }, - "986a776213e8": { - "detected": ["claude"], - "gate": { - "connectInProgress": true, - "error": { - "$rpc": "null" - }, + "needsDesktopServerRecovery": false, "requiresConnection": false, "status": { "$rpc": "null" @@ -63,6 +51,20 @@ "startedAt": 0 } }, + "d3a709c13f9d": { + "detected": ["claude"], + "gate": { + "connectInProgress": true, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": false, + "status": { + "$rpc": "null" + } + } + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -115,7 +117,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "1e4520fe6576", + "state": "5779e5e854f7", "effects": [] } }, @@ -127,7 +129,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "986a776213e8", + "state": "d3a709c13f9d", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/components-target-ssh.json b/mobile/rpc-foundation/goldens/components-target-ssh.json index 928d4187789..faa4a3ed665 100644 --- a/mobile/rpc-foundation/goldens/components-target-ssh.json +++ b/mobile/rpc-foundation/goldens/components-target-ssh.json @@ -76,22 +76,23 @@ "ordinal": 6, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}" }, - "4aa26501c485": { - "name": "preflight.detectRemoteAgents#2", - "ordinal": 8, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" - }, - "4e2e9a890ced": { + "1212a28487d8": { "detected": ["codex"], "gate": { "connectInProgress": false, "error": { "$rpc": "null" }, + "needsDesktopServerRecovery": false, "requiresConnection": false, "status": "connected" } }, + "4aa26501c485": { + "name": "preflight.detectRemoteAgents#2", + "ordinal": 8, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" + }, "70b1e8fb528a": { "name": "preflight.detectRemoteAgents#1", "ordinal": 3, @@ -201,7 +202,15 @@ "startedAt": 0 } }, - "d23b91bd7660": { + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "fe6c500525e6": { "detected": { "$rpc": "null" }, @@ -210,19 +219,12 @@ "error": { "$rpc": "null" }, + "needsDesktopServerRecovery": false, "requiresConnection": true, "status": { "$rpc": "null" } } - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } } }, "recording": { @@ -236,7 +238,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "d23b91bd7660", + "state": "fe6c500525e6", "effects": [] } }, @@ -249,7 +251,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "4e2e9a890ced", + "state": "1212a28487d8", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json index e4254f30cc9..77973bede0a 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json @@ -4,21 +4,6 @@ "family": "components.execution-target-local", "namedDeltas": [], "values": { - "1e4520fe6576": { - "detected": { - "$rpc": "null" - }, - "gate": { - "connectInProgress": true, - "error": { - "$rpc": "null" - }, - "requiresConnection": false, - "status": { - "$rpc": "null" - } - } - }, "2498f3d3369f": { "name": "preflight.detectAgents#1", "ordinal": 1, @@ -51,19 +36,6 @@ } } }, - "365a6864b043": { - "detected": [], - "gate": { - "connectInProgress": true, - "error": { - "$rpc": "null" - }, - "requiresConnection": false, - "status": { - "$rpc": "null" - } - } - }, "453aa52cefe2": { "name": "preflight.detectAgents#1", "ordinal": 1, @@ -136,6 +108,36 @@ } } }, + "5779e5e854f7": { + "detected": { + "$rpc": "null" + }, + "gate": { + "connectInProgress": true, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": false, + "status": { + "$rpc": "null" + } + } + }, + "7097e9b8c337": { + "detected": [], + "gate": { + "connectInProgress": true, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": false, + "status": { + "$rpc": "null" + } + } + }, "74def3993a7c": { "name": "preflight.detectAgents#1", "ordinal": 1, @@ -272,19 +274,6 @@ } } }, - "986a776213e8": { - "detected": ["claude"], - "gate": { - "connectInProgress": true, - "error": { - "$rpc": "null" - }, - "requiresConnection": false, - "status": { - "$rpc": "null" - } - } - }, "9b8ae0c8eec5": { "name": "preflight.detectAgents#1", "ordinal": 2, @@ -347,6 +336,20 @@ } } }, + "d3a709c13f9d": { + "detected": ["claude"], + "gate": { + "connectInProgress": true, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": false, + "status": { + "$rpc": "null" + } + } + }, "db1ccb3eedf8": { "name": "preflight.detectAgents#1", "ordinal": 1, @@ -468,7 +471,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "1e4520fe6576", + "state": "5779e5e854f7", "effects": [] } }, @@ -480,7 +483,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "986a776213e8", + "state": "d3a709c13f9d", "effects": [] } }, @@ -492,7 +495,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "7097e9b8c337", "effects": [] } }, @@ -504,7 +507,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "7097e9b8c337", "effects": [] } }, @@ -516,7 +519,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "7097e9b8c337", "effects": [] } }, @@ -528,7 +531,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "7097e9b8c337", "effects": [] } }, @@ -540,7 +543,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "7097e9b8c337", "effects": [] } }, @@ -552,7 +555,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "7097e9b8c337", "effects": [] } }, @@ -564,7 +567,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "7097e9b8c337", "effects": [] } }, @@ -576,7 +579,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "7097e9b8c337", "effects": [] } }, @@ -588,7 +591,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "7097e9b8c337", "effects": [] } }, @@ -600,7 +603,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "365a6864b043", + "state": "7097e9b8c337", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json index 5dd0685f8e8..c56f5b7e484 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json @@ -111,6 +111,18 @@ "ordinal": 6, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}" }, + "1212a28487d8": { + "detected": ["codex"], + "gate": { + "connectInProgress": false, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": false, + "status": "connected" + } + }, "3dbb3eccbac3": { "name": "preflight.detectRemoteAgents#1", "ordinal": 3, @@ -151,17 +163,6 @@ "ordinal": 8, "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" }, - "4e2e9a890ced": { - "detected": ["codex"], - "gate": { - "connectInProgress": false, - "error": { - "$rpc": "null" - }, - "requiresConnection": false, - "status": "connected" - } - }, "4f7c1dcd099d": { "name": "preflight.detectRemoteAgents#1", "ordinal": 3, @@ -302,13 +303,14 @@ "ordinal": 4, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" }, - "88ecd0c754ca": { + "86a281a96c72": { "detected": [], "gate": { "connectInProgress": false, "error": { "$rpc": "null" }, + "needsDesktopServerRecovery": false, "requiresConnection": false, "status": "connected" } @@ -518,21 +520,6 @@ } } }, - "d23b91bd7660": { - "detected": { - "$rpc": "null" - }, - "gate": { - "connectInProgress": false, - "error": { - "$rpc": "null" - }, - "requiresConnection": true, - "status": { - "$rpc": "null" - } - } - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -574,6 +561,22 @@ } } } + }, + "fe6c500525e6": { + "detected": { + "$rpc": "null" + }, + "gate": { + "connectInProgress": false, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": true, + "status": { + "$rpc": "null" + } + } } }, "recording": { @@ -587,7 +590,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "d23b91bd7660", + "state": "fe6c500525e6", "effects": [] } }, @@ -600,7 +603,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "4e2e9a890ced", + "state": "1212a28487d8", "effects": [] } }, @@ -613,7 +616,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "88ecd0c754ca", + "state": "86a281a96c72", "effects": [] } }, @@ -626,7 +629,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "88ecd0c754ca", + "state": "86a281a96c72", "effects": [] } }, @@ -639,7 +642,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "88ecd0c754ca", + "state": "86a281a96c72", "effects": [] } }, @@ -652,7 +655,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "88ecd0c754ca", + "state": "86a281a96c72", "effects": [] } }, @@ -665,7 +668,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "88ecd0c754ca", + "state": "86a281a96c72", "effects": [] } }, @@ -678,7 +681,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "88ecd0c754ca", + "state": "86a281a96c72", "effects": [] } }, @@ -691,7 +694,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "88ecd0c754ca", + "state": "86a281a96c72", "effects": [] } }, @@ -704,7 +707,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "88ecd0c754ca", + "state": "86a281a96c72", "effects": [] } }, @@ -717,7 +720,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "88ecd0c754ca", + "state": "86a281a96c72", "effects": [] } }, @@ -730,7 +733,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "88ecd0c754ca", + "state": "86a281a96c72", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json index 945caa098c6..6158e78eeeb 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json @@ -107,6 +107,18 @@ "ordinal": 6, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}" }, + "1212a28487d8": { + "detected": ["codex"], + "gate": { + "connectInProgress": false, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": false, + "status": "connected" + } + }, "1a1c006a53b2": { "name": "ssh.connect#1", "ordinal": 5, @@ -141,35 +153,38 @@ } } }, - "2d47b46a5872": { - "detected": { - "$rpc": "null" - }, - "gate": { - "connectInProgress": false, - "error": "Unknown method", - "requiresConnection": true, - "status": "error" - } - }, - "3828494e64df": { + "2736bf269de9": { "detected": { "$rpc": "null" }, "gate": { "connectInProgress": false, "error": "", + "needsDesktopServerRecovery": false, "requiresConnection": true, "status": "error" } }, - "467f1a0954f0": { + "31cf96607dc7": { "detected": { "$rpc": "null" }, "gate": { "connectInProgress": false, "error": "transport failure", + "needsDesktopServerRecovery": false, + "requiresConnection": true, + "status": "error" + } + }, + "362b036cb4aa": { + "detected": { + "$rpc": "null" + }, + "gate": { + "connectInProgress": false, + "error": "Unknown method", + "needsDesktopServerRecovery": false, "requiresConnection": true, "status": "error" } @@ -214,15 +229,16 @@ "ordinal": 8, "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" }, - "4e2e9a890ced": { - "detected": ["codex"], + "53cfc4ee0cf6": { + "detected": { + "$rpc": "null" + }, "gate": { "connectInProgress": false, - "error": { - "$rpc": "null" - }, - "requiresConnection": false, - "status": "connected" + "error": "outer refused", + "needsDesktopServerRecovery": false, + "requiresConnection": true, + "status": "error" } }, "5bab1ad3009a": { @@ -332,17 +348,6 @@ "ordinal": 4, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" }, - "990a404630b4": { - "detected": { - "$rpc": "null" - }, - "gate": { - "connectInProgress": false, - "error": "outer refused", - "requiresConnection": true, - "status": "error" - } - }, "9eb936085325": { "name": "ssh.connect#1", "ordinal": 5, @@ -550,28 +555,14 @@ } } }, - "d23b91bd7660": { - "detected": { - "$rpc": "null" - }, - "gate": { - "connectInProgress": false, - "error": { - "$rpc": "null" - }, - "requiresConnection": true, - "status": { - "$rpc": "null" - } - } - }, - "eaf7ed4ebea4": { + "c2a9e7754a2e": { "detected": { "$rpc": "null" }, "gate": { "connectInProgress": false, "error": "The host sent a reply this app could not read (ssh.connect)", + "needsDesktopServerRecovery": false, "requiresConnection": true, "status": "error" } @@ -618,6 +609,22 @@ } } } + }, + "fe6c500525e6": { + "detected": { + "$rpc": "null" + }, + "gate": { + "connectInProgress": false, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": true, + "status": { + "$rpc": "null" + } + } } }, "recording": { @@ -631,7 +638,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "d23b91bd7660", + "state": "fe6c500525e6", "effects": [] } }, @@ -644,7 +651,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "4e2e9a890ced", + "state": "1212a28487d8", "effects": [] } }, @@ -657,7 +664,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "eaf7ed4ebea4", + "state": "c2a9e7754a2e", "effects": [] } }, @@ -670,7 +677,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "eaf7ed4ebea4", + "state": "c2a9e7754a2e", "effects": [] } }, @@ -683,7 +690,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "4e2e9a890ced", + "state": "1212a28487d8", "effects": [] } }, @@ -696,7 +703,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "4e2e9a890ced", + "state": "1212a28487d8", "effects": [] } }, @@ -709,7 +716,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "4e2e9a890ced", + "state": "1212a28487d8", "effects": [] } }, @@ -722,7 +729,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "990a404630b4", + "state": "53cfc4ee0cf6", "effects": [] } }, @@ -735,7 +742,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "3828494e64df", + "state": "2736bf269de9", "effects": [] } }, @@ -748,7 +755,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "2d47b46a5872", + "state": "362b036cb4aa", "effects": [] } }, @@ -761,7 +768,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "467f1a0954f0", + "state": "31cf96607dc7", "effects": [] } }, @@ -774,7 +781,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "3828494e64df", + "state": "2736bf269de9", "effects": [] } } diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json index 6e02a2a9e75..29f95cccb99 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json @@ -76,6 +76,18 @@ "ordinal": 6, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}" }, + "1212a28487d8": { + "detected": ["codex"], + "gate": { + "connectInProgress": false, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": false, + "status": "connected" + } + }, "196cd7f8b6a9": { "name": "ssh.getState#1", "ordinal": 1, @@ -195,17 +207,6 @@ "ordinal": 8, "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" }, - "4e2e9a890ced": { - "detected": ["codex"], - "gate": { - "connectInProgress": false, - "error": { - "$rpc": "null" - }, - "requiresConnection": false, - "status": "connected" - } - }, "70b1e8fb528a": { "name": "preflight.detectRemoteAgents#1", "ordinal": 3, @@ -238,6 +239,20 @@ } } }, + "7f7bf8a1f3fa": { + "detected": { + "$rpc": "null" + }, + "gate": { + "connectInProgress": false, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": false, + "status": "connected" + } + }, "8265ec539d5a": { "name": "preflight.detectRemoteAgents#1", "ordinal": 4, @@ -514,21 +529,6 @@ "startedAt": 0 } }, - "d23b91bd7660": { - "detected": { - "$rpc": "null" - }, - "gate": { - "connectInProgress": false, - "error": { - "$rpc": "null" - }, - "requiresConnection": true, - "status": { - "$rpc": "null" - } - } - }, "d4293c213b99": { "name": "ssh.getState#1", "ordinal": 1, @@ -598,19 +598,6 @@ } } }, - "e314f3903bd3": { - "detected": { - "$rpc": "null" - }, - "gate": { - "connectInProgress": false, - "error": { - "$rpc": "null" - }, - "requiresConnection": false, - "status": "connected" - } - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -653,6 +640,22 @@ "ok": false } } + }, + "fe6c500525e6": { + "detected": { + "$rpc": "null" + }, + "gate": { + "connectInProgress": false, + "error": { + "$rpc": "null" + }, + "needsDesktopServerRecovery": false, + "requiresConnection": true, + "status": { + "$rpc": "null" + } + } } }, "recording": { @@ -666,7 +669,7 @@ "settlements": { "mount": "eb79a9b3682a" }, - "state": "d23b91bd7660", + "state": "fe6c500525e6", "effects": [] } }, @@ -679,7 +682,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "4e2e9a890ced", + "state": "1212a28487d8", "effects": [] } }, @@ -692,7 +695,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "e314f3903bd3", + "state": "7f7bf8a1f3fa", "effects": [] } }, @@ -705,7 +708,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "e314f3903bd3", + "state": "7f7bf8a1f3fa", "effects": [] } }, @@ -718,7 +721,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "e314f3903bd3", + "state": "7f7bf8a1f3fa", "effects": [] } }, @@ -731,7 +734,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "e314f3903bd3", + "state": "7f7bf8a1f3fa", "effects": [] } }, @@ -744,7 +747,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "e314f3903bd3", + "state": "7f7bf8a1f3fa", "effects": [] } }, @@ -757,7 +760,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "e314f3903bd3", + "state": "7f7bf8a1f3fa", "effects": [] } }, @@ -770,7 +773,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "e314f3903bd3", + "state": "7f7bf8a1f3fa", "effects": [] } }, @@ -783,7 +786,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "e314f3903bd3", + "state": "7f7bf8a1f3fa", "effects": [] } }, @@ -796,7 +799,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "e314f3903bd3", + "state": "7f7bf8a1f3fa", "effects": [] } }, @@ -809,7 +812,7 @@ "mount": "eb79a9b3682a", "connect": "eb79a9b3682a" }, - "state": "e314f3903bd3", + "state": "7f7bf8a1f3fa", "effects": [] } } diff --git a/mobile/src/components/NewWorkspaceSshConnectionField.tsx b/mobile/src/components/NewWorkspaceSshConnectionField.tsx index 38e3eeaea9b..baa1b228c97 100644 --- a/mobile/src/components/NewWorkspaceSshConnectionField.tsx +++ b/mobile/src/components/NewWorkspaceSshConnectionField.tsx @@ -46,6 +46,11 @@ export function NewWorkspaceSshConnectionField({ )} {sshGate.error ? {sshGate.error} : null} + {sshGate.needsDesktopServerRecovery ? ( + + Its server needs recovering from the desktop: Settings → SSH hosts → Recover server. + + ) : null} ) diff --git a/mobile/src/tasks/workspace-source-reply-schema.test.ts b/mobile/src/tasks/workspace-source-reply-schema.test.ts index a8ae643afeb..b4d2fad0abe 100644 --- a/mobile/src/tasks/workspace-source-reply-schema.test.ts +++ b/mobile/src/tasks/workspace-source-reply-schema.test.ts @@ -45,6 +45,14 @@ describe('the SSH connection record', () => { }) }) + it('keeps the record when its managed-server note is malformed', () => { + const parsed = sshConnectionStateSchema.safeParse({ + state: { ...connected, managedServer: { serving: { detail: 7 } } } + }) + expect(parsed.success && parsed.data).toMatchObject({ status: 'connected' }) + expect(parsed.success && parsed.data?.managedServer?.serving?.detail).toBeUndefined() + }) + it('answers undefined for a payload with no state member', () => { expect(sshConnectionStateSchema.safeParse({})).toMatchObject({ success: true, data: undefined }) }) diff --git a/mobile/src/tasks/workspace-source-reply-schema.ts b/mobile/src/tasks/workspace-source-reply-schema.ts index f383e30f92d..0a3bc7a49d9 100644 --- a/mobile/src/tasks/workspace-source-reply-schema.ts +++ b/mobile/src/tasks/workspace-source-reply-schema.ts @@ -61,7 +61,21 @@ export const sshConnectionStateSchema = z status: openEnum(SSH_CONNECTION_STATUS, 'disconnected'), error: salvagedOptional('error', z.string().nullable()), reconnectAttempt: salvagedOptional('reconnectAttempt', z.number()), - connectionGeneration: salvagedOptional('connectionGeneration', z.number()) + connectionGeneration: salvagedOptional('connectionGeneration', z.number()), + // Only the start refusal is read, for the desktop-recovery hint; old hosts omit it. + managedServer: salvagedOptional( + 'managedServer', + z.looseObject({ + kind: salvagedOptional('kind', z.string()), + serving: salvagedOptional( + 'serving', + z.looseObject({ + state: salvagedOptional('state', z.string()), + detail: salvagedOptional('detail', z.string()) + }) + ) + }) + ) }) .nullable() ) diff --git a/mobile/src/tasks/workspace-ssh-gate.test.ts b/mobile/src/tasks/workspace-ssh-gate.test.ts index c0bfce8c539..6f5ed54d94f 100644 --- a/mobile/src/tasks/workspace-ssh-gate.test.ts +++ b/mobile/src/tasks/workspace-ssh-gate.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from './workspace-ssh-gate' +import { + deriveWorkspaceSshGate, + workspaceSshStatusLabel, + type WorkspaceSshRecord +} from './workspace-ssh-gate' describe('workspace SSH gate', () => { it('does not gate local repositories', () => { @@ -13,7 +17,8 @@ describe('workspace SSH gate', () => { status: null, requiresConnection: false, connectInProgress: false, - error: null + error: null, + needsDesktopServerRecovery: false }) }) @@ -46,7 +51,8 @@ describe('workspace SSH gate', () => { status: null, requiresConnection: true, connectInProgress: false, - error: null + error: null, + needsDesktopServerRecovery: false }) }) @@ -82,6 +88,23 @@ describe('workspace SSH gate', () => { }) }) + it('asks for the desktop recovery only for the refusal the desktop offers it on', () => { + const named = 'run `orcad --reclaim-stale-lock --data-root /home/dev/.orca` there' + const gate = (managedServer: WorkspaceSshRecord['managedServer']) => + deriveWorkspaceSshGate({ + connectionId: 'ssh-1', + state: { targetId: 'ssh-1', status: 'connected', managedServer }, + connecting: false + }).needsDesktopServerRecovery + const refused = { kind: 'managed', serving: { state: 'unverifiable', detail: named } } + expect(gate(refused)).toBe(true) + expect(gate({ ...refused, serving: { ...refused.serving, detail: 'did not answer' } })).toBe( + false + ) + expect(gate({ ...refused, kind: 'relay' })).toBe(false) + expect(gate({ ...refused, serving: { detail: named } })).toBe(false) + }) + it('labels user-visible SSH states', () => { expect(workspaceSshStatusLabel(null)).toBe('Disconnected') expect(workspaceSshStatusLabel('deploying-relay')).toBe('Deploying relay') diff --git a/mobile/src/tasks/workspace-ssh-gate.ts b/mobile/src/tasks/workspace-ssh-gate.ts index f031ed5fd5b..0a7fd0d6c4e 100644 --- a/mobile/src/tasks/workspace-ssh-gate.ts +++ b/mobile/src/tasks/workspace-ssh-gate.ts @@ -1,4 +1,5 @@ import type { SshConnectionState, SshConnectionStatus } from '../../../src/shared/ssh-types' +import { ORCAD_RECLAIM_STALE_LOCK_FLAG } from '../../../src/shared/orcad-stale-lock-reclaim' /** * What mobile stores for an SSH connection. @@ -9,9 +10,13 @@ import type { SshConnectionState, SshConnectionStatus } from '../../../src/share * keeps its `status` instead of dropping the whole record into the connect path's `'connected'` * fallback. */ -export type WorkspaceSshRecord = Omit & { +export type WorkspaceSshRecord = Omit< + SshConnectionState, + 'error' | 'reconnectAttempt' | 'managedServer' +> & { error?: string | null reconnectAttempt?: number + managedServer?: { kind?: string; serving?: { state?: string; detail?: string } } } export type WorkspaceSshGate = { @@ -19,6 +24,8 @@ export type WorkspaceSshGate = { requiresConnection: boolean connectInProgress: boolean error: string | null + /** The host's server refused to start on a possibly stale lock; only the desktop can clear it. */ + needsDesktopServerRecovery: boolean } function isWorkspaceSshConnectInProgress(status: SshConnectionStatus | null): boolean { @@ -62,6 +69,16 @@ export function deriveWorkspaceSshGate(args: { status, requiresConnection: args.connectionId !== null && status !== 'connected', connectInProgress: args.connecting || isWorkspaceSshConnectInProgress(status), - error: matchingState?.error ?? null + error: matchingState?.error ?? null, + needsDesktopServerRecovery: needsDesktopServerRecovery(matchingState?.managedServer) } } + +/** The desktop's own gate for Recover server (canRecoverSshHostServer), read from a lenient record. */ +function needsDesktopServerRecovery(server: WorkspaceSshRecord['managedServer']): boolean { + return ( + server?.kind === 'managed' && + server.serving?.state === 'unverifiable' && + Boolean(server.serving.detail?.includes(ORCAD_RECLAIM_STALE_LOCK_FLAG)) + ) +} diff --git a/src/main/ipc/runtime-environments/managed-orcad-actions.ts b/src/main/ipc/runtime-environments/managed-orcad-actions.ts index 59bd3d06151..d76b2256600 100644 --- a/src/main/ipc/runtime-environments/managed-orcad-actions.ts +++ b/src/main/ipc/runtime-environments/managed-orcad-actions.ts @@ -12,8 +12,13 @@ import { } from '../../ssh/orcad-runtime-maintenance' import { getOrcadSshDeployment } from '../../../shared/runtime-environments' import { resolveEnvironment } from '../../../shared/runtime-environment-store' -import type { OrcadManagedRestartResult } from '../../../shared/orcad-managed-runtime' +import type { + OrcadManagedRestartResult, + OrcadManagedStaleLockReclaimRequest, + OrcadManagedStaleLockReclaimResult +} from '../../../shared/orcad-managed-runtime' import { restartManagedOrcadEnvironment } from '../../managed-server/lifecycle/orcad-managed-launch-session' +import { runManagedOrcadStaleLockReclaim } from '../../managed-server/lifecycle/orcad-managed-stale-lock-reclaim' import { clearLaunchSessionNote } from './runtime-environment-managed-tunnel' import { forgetLaunchSessionVerdict } from '../ssh/ssh-host-launch-session-note' import type { ManagedServerActions } from '../../runtime/managed-server-actions-registry' @@ -102,15 +107,36 @@ export async function restartManagedOrcad( ): Promise { const result = await restartManagedOrcadEnvironment(options.getUserDataPath(), selector) if (result.outcome === 'restarted') { - // Why: the restarted orcad dropped the old connection; reconnect on the new one. - await options.invalidateTransport(result.environmentId) - const environment = resolveEnvironment(options.getUserDataPath(), result.environmentId) - const targetId = getOrcadSshDeployment(environment)?.sshTargetId - if (targetId) { - options.clearHostServerNotes(targetId, result.environmentId) - forgetLaunchSessionVerdict(targetId) - clearLaunchSessionNote(targetId, result.environmentId) - } + await reconnectRestartedManagedOrcad(options, result.environmentId) } return result } + +/** Desktop-only: the host status line offers it when a start refused a possibly stale lock (#32177). */ +export async function reclaimManagedOrcadStaleLock( + options: ManagedOrcadActionOptions, + selector: string, + request: OrcadManagedStaleLockReclaimRequest +): Promise { + const result = await runManagedOrcadStaleLockReclaim(options.getUserDataPath(), selector, request) + // Even unstarted: the reconnect's own start replaces the note that offered this. + if (result.status === 'reclaimed') { + await reconnectRestartedManagedOrcad(options, result.environmentId) + } + return result +} + +async function reconnectRestartedManagedOrcad( + options: ManagedOrcadActionOptions, + environmentId: string +): Promise { + // Why: the restarted orcad dropped the old connection; reconnect on the new one. + await options.invalidateTransport(environmentId) + const environment = resolveEnvironment(options.getUserDataPath(), environmentId) + const targetId = getOrcadSshDeployment(environment)?.sshTargetId + if (targetId) { + options.clearHostServerNotes(targetId, environmentId) + forgetLaunchSessionVerdict(targetId) + clearLaunchSessionNote(targetId, environmentId) + } +} diff --git a/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.ts b/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.ts index 4ed7464cc52..3cbff5b56f9 100644 --- a/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.ts +++ b/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { registerManagedServerActions } from '../../runtime/managed-server-actions-registry' import { createManagedOrcadActions, + reclaimManagedOrcadStaleLock, restartManagedOrcad, type ManagedOrcadActionOptions } from './managed-orcad-actions' @@ -27,6 +28,18 @@ export function registerOrcadRuntimeMaintenanceHandlers(options: ManagedOrcadAct ipcMain.handle('runtimeEnvironments:restartOrcad', async (_event, args: { selector: string }) => restartManagedOrcad(options, requiredString(args?.selector, 'Server')) ) + ipcMain.handle( + 'runtimeEnvironments:reclaimOrcadStaleLock', + async (_event, args: { selector: string; yes?: boolean; expectNonce?: string }) => + reclaimManagedOrcadStaleLock( + options, + requiredString(args?.selector, 'Server'), + // A confirmation without the checked record's nonce could clear a lock written since. + args?.yes === true + ? { yes: true, expectNonce: requiredString(args.expectNonce, 'The checked lock') } + : { yes: false } + ) + ) ipcMain.handle('runtimeEnvironments:stopOrcad', async (_event, args: { selector: string }) => actions.stop(requiredString(args?.selector, 'Server')) ) diff --git a/src/main/ipc/runtime-environments/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environments/runtime-environment-handler-channels.ts index ba9dba5aff6..39fdd868488 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-handler-channels.ts @@ -21,6 +21,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:rollbackOrcad', 'runtimeEnvironments:recoverOrcad', 'runtimeEnvironments:restartOrcad', + 'runtimeEnvironments:reclaimOrcadStaleLock', 'runtimeEnvironments:stopOrcad', 'runtimeEnvironments:cancelOrcadStop', 'runtimeEnvironments:forgetOrcad', diff --git a/src/main/managed-server/install/orcad-remote-host-support.ts b/src/main/managed-server/install/orcad-remote-host-support.ts index c25d37f49e5..2d58eb9c1bf 100644 --- a/src/main/managed-server/install/orcad-remote-host-support.ts +++ b/src/main/managed-server/install/orcad-remote-host-support.ts @@ -34,6 +34,11 @@ export const ORCAD_READINESS_MAX_BYTES = 256 * 1024 /** PID of the launched orcad, written into its own version dir at launch. */ export const ORCAD_PID_FILENAME = '.orcad-pid' +/** The machine that wrote `.orcad-pid` (boot id, then node name): a shared home is no machine. */ +export const ORCAD_PID_MACHINE_FILENAME = `${ORCAD_PID_FILENAME}-machine` +/** Prints that record, or nothing on a host with no boot id; a launch and a later wait compare it. */ +export const ORCAD_LAUNCH_MACHINE_COMMAND = 'cat /proc/sys/kernel/random/boot_id&&uname -n' + /** Windows' `.orcad-pid`: `{"pid":N,"creationTimeMs":M|null}`, since a PID alone is no identity there. */ export const ORCAD_WINDOWS_PROCESS_FILENAME = '.orcad-process.json' diff --git a/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts b/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts index b154799d811..869e1bf35d8 100644 --- a/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts +++ b/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts @@ -46,11 +46,12 @@ describe('the WSL launch (setsid)', () => { it('has the child record its own PID, since the setsid -f parent exits at once', () => { const command = orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid') - const removeStale = command.indexOf(`rm -f '/d/orcad-1/${ORCAD_PID_FILENAME}'`) + expect(command.startsWith(`orcad_pid='/d/orcad-1/${ORCAD_PID_FILENAME}';`)).toBe(true) + const removeStale = command.indexOf('rm -f "$orcad_pid"') const recordOwn = command.indexOf(`echo $$ > "$1" && umask "$2" && shift 2 && exec "$@"`) expect(removeStale).toBeGreaterThan(-1) expect(recordOwn).toBeGreaterThan(removeStale) - expect(command.trimEnd().endsWith(`cat '/d/orcad-1/${ORCAD_PID_FILENAME}'`)).toBe(true) + expect(command.trimEnd().endsWith('cat "$orcad_pid"')).toBe(true) }) it('passes host facts the guest cannot read, quoted, and refuses a malformed name', () => { diff --git a/src/main/managed-server/install/orcad-remote-launch.ts b/src/main/managed-server/install/orcad-remote-launch.ts index 6d6e7ab7911..f3fdf5fae35 100644 --- a/src/main/managed-server/install/orcad-remote-launch.ts +++ b/src/main/managed-server/install/orcad-remote-launch.ts @@ -31,6 +31,8 @@ import { assertPosixOrcadHost as assertPosixHost, ORCAD_LOG_FILENAME, ORCAD_PID_FILENAME, + ORCAD_LAUNCH_MACHINE_COMMAND, + ORCAD_PID_MACHINE_FILENAME, ORCAD_READINESS_FILENAME, ORCAD_READINESS_MAX_BYTES, posixProcessAliveShellFunction @@ -105,7 +107,8 @@ export function orcadLaunchCommand( const readiness = quotePosixShell(ORCAD_READINESS_FILENAME) const log = quotePosixShell(ORCAD_LOG_FILENAME) // Absolute: a nohup launch backgrounds the whole list, `cd` included, then writes this outside it. - const pidFile = quotePosixShell(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME)) + // Named once: the path is long, and every byte counts against the sent-command budget. + const pidFile = '"$orcad_pid"' // Absolute: liveness matches the runtime's args against this path. const entry = quotePosixShell( joinRemotePath(host, spec.remoteInstallDir, ORCAD_LAUNCHER_FILENAME) @@ -124,6 +127,9 @@ export function orcadLaunchCommand( ...orcadManagedLaunchEnv(spec).map(([name, value]) => `${name}=${quotePosixShell(value)}`) ] return [ + `orcad_pid=${quotePosixShell(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME))};`, + // In the launching shell, so on the launched process's machine; empty where there is no boot id. + `{ ${ORCAD_LAUNCH_MACHINE_COMMAND};}>"$orcad_pid${ORCAD_PID_MACHINE_FILENAME.slice(ORCAD_PID_FILENAME.length)}" 2>/dev/null;`, ...(recordSession ? [captureOrcadLaunchSshSessionCommand()] : []), // The daemon gets this back: the git, terminals and agents it spawns create the user's files. 'orcad_login_umask=$(umask);', diff --git a/src/main/managed-server/install/orcad-remote-readiness-wait.test.ts b/src/main/managed-server/install/orcad-remote-readiness-wait.test.ts index 8d3aed0e73c..c2e035f9ab7 100644 --- a/src/main/managed-server/install/orcad-remote-readiness-wait.test.ts +++ b/src/main/managed-server/install/orcad-remote-readiness-wait.test.ts @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { runProcess } from '@orca/process-host' import { orcadReadinessWaitCommand } from './orcad-remote-readiness-wait' import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' +import { ORCAD_LAUNCH_MACHINE_COMMAND } from './orcad-remote-host-support' const roots: string[] = [] afterEach(() => { @@ -37,4 +39,44 @@ describe.skipIf(process.platform === 'win32')('the host-side readiness wait', () // A counted loop would take about 8 x (0.25 s + 1 s) here. expect(Date.now() - startedAt).toBeLessThan(4_500) }) + + async function waitAfter(gone: boolean, machine: 'here' | 'elsewhere' | 'unrecorded') { + const root = mkdtempSync(join(tmpdir(), 'orcad-readiness-wait-')) + roots.push(root) + const slot = join(root, 'slot') + mkdirSync(slot) + const pidFile = quotePosixShell(join(slot, '.orcad-pid')) + const machineFile = quotePosixShell(join(slot, '.orcad-pid-machine')) + const command = orcadReadinessWaitCommand(getRemoteHostPlatform('linux-x64'), slot, 2) + const prefix = [ + gone ? `sh -c 'echo $$ > "$1"' orcad-test ${pidFile};` : `echo ${process.pid} > ${pidFile};`, + ...(machine === 'here' + ? [`{ ${ORCAD_LAUNCH_MACHINE_COMMAND};} > ${machineFile} 2>/dev/null;`] + : machine === 'elsewhere' + ? [`echo 'other-boot other-node' > ${machineFile};`] + : []) + ].join(' ') + const { stdout } = await runProcess({ + program: '/bin/sh', + args: ['-c', `${prefix} ${command}`], + timeoutMs: 30_000 + }) + return stdout.startsWith('ORCAD_LAUNCH_EXITED\n') + } + + // Only Linux has the boot id that makes a PID evidence; elsewhere the wait keeps its deadline. + it.skipIf(process.platform !== 'linux')( + 'ends at once when the launched PID is gone here', + async () => { + expect(await waitAfter(true, 'here')).toBe(true) + } + ) + + it.each([ + ['waits while the launched PID still runs', false, 'here'], + ['waits for a PID another machine sharing the home launched', true, 'elsewhere'], + ['waits for a launch that recorded no machine', true, 'unrecorded'] + ] as const)('%s', async (_case, gone, machine) => { + expect(await waitAfter(gone, machine)).toBe(false) + }) }) diff --git a/src/main/managed-server/install/orcad-remote-readiness-wait.ts b/src/main/managed-server/install/orcad-remote-readiness-wait.ts index f185f18cbca..42ed5a8e837 100644 --- a/src/main/managed-server/install/orcad-remote-readiness-wait.ts +++ b/src/main/managed-server/install/orcad-remote-readiness-wait.ts @@ -12,7 +12,13 @@ import { joinRemotePath, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' -import { ORCAD_READINESS_FILENAME, ORCAD_READINESS_MAX_BYTES } from './orcad-remote-host-support' +import { + ORCAD_PID_FILENAME, + ORCAD_PID_MACHINE_FILENAME, + ORCAD_READINESS_FILENAME, + ORCAD_READINESS_MAX_BYTES, + ORCAD_LAUNCH_MACHINE_COMMAND +} from './orcad-remote-host-support' import { parseOrcadReadinessOutput, readOrcadReadinessCommand, @@ -27,6 +33,9 @@ import { ORCAD_WINDOWS_READINESS_MARKER } from '../host-state/orcad-windows-host /** Under the 30 s exec timeout, so one wait never reads as an unanswered host. */ export const ORCAD_READINESS_WAIT_MAX_SECONDS = 20 +/** Printed ahead of the readiness bytes when the host proved the launched PID gone. */ +const ORCAD_LAUNCH_EXITED_MARKER = 'ORCAD_LAUNCH_EXITED' + /** Settles on a newline (a finished line) or more than the cap; both are final for the parser. */ function posixReadinessWaitCommand( host: RemoteHostPlatform, @@ -35,7 +44,18 @@ function posixReadinessWaitCommand( ): string { const file = quotePosixShell(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) const read = `head -c ${ORCAD_READINESS_MAX_BYTES + 1} ${file} 2>/dev/null` + const pidFile = quotePosixShell(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + const pidMachine = quotePosixShell( + joinRemotePath(host, remoteInstallDir, ORCAD_PID_MACHINE_FILENAME) + ) return [ + // Why: a start refused at once (#32177) otherwise reads as pending until the client's deadline. + // Proven exit only: no PID, a denied probe or a reused PID keeps waiting. A PID is evidence + // only on the machine that launched it; a shared home may put this exec on another one. + `orcad_launch_here() { orcad_recorded=$(cat ${pidMachine} 2>/dev/null) && [ -n "$orcad_recorded" ] && [ "$orcad_recorded" = "$(${ORCAD_LAUNCH_MACHINE_COMMAND} 2>/dev/null)" ]; };`, + `orcad_launch_exited() { orcad_launch_here || return 1; pid=$(cat ${pidFile} 2>/dev/null); case "$pid" in "" | *[!0-9]*) return 1;; esac;`, + 'probe_error=$(LC_ALL=C kill -0 "$pid" 2>&1) || { case "$probe_error" in *"No such process"*) return 0;; esac; return 1; };', + 'case "$(ps -o stat= -p "$pid" 2>/dev/null)" in Z*) return 0;; esac; return 1; };', // Fractional sleep is not POSIX; probe it once so polling stays fine-grained where it can. 'if sleep 0.25 2>/dev/null; then orcad_readiness_wait_step=0.25;', 'else orcad_readiness_wait_step=1; fi;', @@ -46,6 +66,7 @@ function posixReadinessWaitCommand( 'while [ "$(date +%s)" -lt "$orcad_readiness_wait_end" ]; do', `[ "$(${read} | wc -l)" -gt 0 ] && break;`, `[ "$(${read} | wc -c)" -gt ${ORCAD_READINESS_MAX_BYTES} ] && break;`, + `orcad_launch_exited && { echo ${ORCAD_LAUNCH_EXITED_MARKER}; break; };`, 'sleep "$orcad_readiness_wait_step"; done;', `${read} || true` ].join(' ') @@ -75,10 +96,21 @@ export function parseOrcadReadinessWaitOutput( return parseOrcadReadinessOutput( isWindowsRemoteHost(host) ? (readOrcadWindowsEncodedAnswer(output, ORCAD_WINDOWS_READINESS_MARKER) ?? '') - : output + : withoutLaunchExitedMarker(output) ) } +/** The host proved the launched process gone while its readiness was still unwritten. */ +export function orcadLaunchExitedDuringWait(host: RemoteHostPlatform, output: string): boolean { + return !isWindowsRemoteHost(host) && output.startsWith(`${ORCAD_LAUNCH_EXITED_MARKER}\n`) +} + +function withoutLaunchExitedMarker(output: string): string { + return output.startsWith(`${ORCAD_LAUNCH_EXITED_MARKER}\n`) + ? output.slice(ORCAD_LAUNCH_EXITED_MARKER.length + 1) + : output +} + /** Reads the readiness line as it stands; parse with `parseOrcadReadinessWaitOutput`. */ export function readOrcadReadinessNowCommand( host: RemoteHostPlatform, diff --git a/src/main/managed-server/install/orcad-remote-runtime-control.test.ts b/src/main/managed-server/install/orcad-remote-runtime-control.test.ts index 63feb7c6b8b..2d2f3787f4f 100644 --- a/src/main/managed-server/install/orcad-remote-runtime-control.test.ts +++ b/src/main/managed-server/install/orcad-remote-runtime-control.test.ts @@ -13,6 +13,7 @@ import { launchOrcadAndAwaitReadiness } from './orcad-remote-runtime-control' import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' import type { OrcadLaunchSpec } from './orcad-remote-launch' import { createSshOperationAbortError } from '../../ssh/connection/ssh-exec-options' +import { launchOrcadSlotAndAwaitReadiness } from '../../ssh/orcad-active-readiness' const host = getRemoteHostPlatform('linux-arm64') const spec: OrcadLaunchSpec = { @@ -77,3 +78,60 @@ describe('waiting for a launched candidate', () => { await expect(launch(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) }) }) + +describe('a launch that exits before it is ready', () => { + const REFUSAL = 'orcad: failed to start: another machine may hold the data root' + let now = 0 + beforeEach(() => { + mockExec.mockReset() + now = 0 + vi.spyOn(Date, 'now').mockImplementation(() => now) + }) + + // The host answers each wait at once; only the client's injected sleep moves the clock. + function waitAnswers(exitedOnPoll: number | null) { + let polls = 0 + mockExec.mockImplementation(async (_conn, command) => { + if (command.includes('tail -c')) { + return `${REFUSAL}\n` + } + if (command.includes('orcad_readiness_wait_end')) { + polls += 1 + return polls === exitedOnPoll ? 'ORCAD_LAUNCH_EXITED\n' : '' + } + return '1786\n' + }) + return () => polls + } + + function launchSlot() { + return launchOrcadSlotAndAwaitReadiness( + { + conn: Object.create(null), + host, + readinessTimeoutMs: 180_000, + sleep: async (ms) => { + now += ms + } + }, + spec, + { fullVersion: spec.fullVersion, buildHash: 'b1' } + ) + } + + it('fails at once with the log’s refusal once the host proved the PID gone', async () => { + const polls = waitAnswers(2) + const failure = launchSlot() + await expect(failure).rejects.toMatchObject({ verdict: 'rejected' }) + await expect(failure).rejects.toThrow(REFUSAL) + expect(polls()).toBe(2) + expect(now).toBeLessThan(5_000) + }) + + it('waits out the deadline when the host never proves an exit', async () => { + const polls = waitAnswers(null) + await expect(launchSlot()).rejects.toThrow(REFUSAL) + expect(now).toBeGreaterThanOrEqual(180_000) + expect(polls()).toBeGreaterThan(100) + }) +}) diff --git a/src/main/managed-server/install/orcad-remote-runtime-control.ts b/src/main/managed-server/install/orcad-remote-runtime-control.ts index 3435312090d..bf5098c59c6 100644 --- a/src/main/managed-server/install/orcad-remote-runtime-control.ts +++ b/src/main/managed-server/install/orcad-remote-runtime-control.ts @@ -15,6 +15,7 @@ import { } from './orcad-remote-launch-windows' import { ORCAD_READINESS_WAIT_MAX_SECONDS, + orcadLaunchExitedDuringWait, orcadReadinessWaitCommand, parseOrcadReadinessWaitOutput } from './orcad-remote-readiness-wait' @@ -165,7 +166,8 @@ export async function launchOrcadAndAwaitReadiness( target.signal?.throwIfAborted() lastWaitError = undefined last = parseOrcadReadinessWaitOutput(target.host, output) - if (last.state !== 'pending') { + // An exited launch answers as the deadline would, so callers attach the same log tail. + if (last.state !== 'pending' || orcadLaunchExitedDuringWait(target.host, output)) { return last } await sleep(READINESS_RETRY_PAUSE_MS) diff --git a/src/main/managed-server/lifecycle/orcad-managed-stale-lock-reclaim.test.ts b/src/main/managed-server/lifecycle/orcad-managed-stale-lock-reclaim.test.ts new file mode 100644 index 00000000000..bc376602df2 --- /dev/null +++ b/src/main/managed-server/lifecycle/orcad-managed-stale-lock-reclaim.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeMaintenanceModule from '../../ssh/orcad-runtime-maintenance' +import { getRemoteHostPlatform, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' +import { sshCommandExitError } from '../../ssh/remote-exec/remote-exec-command' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProcess } from '@orca/process-host' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' +import { ORCAD_INSTALL_MODEL } from '../install/remote-install-model' +import { computeRemoteInstallDir } from '../install/remote-versioned-install' + +const mocks = vi.hoisted(() => { + const host: { current: RemoteHostPlatform | null } = { current: null } + const home = { current: '/home/dev' } + return { host, home, exec: vi.fn(), wake: vi.fn(), rebuild: vi.fn(async () => {}) } +}) + +vi.mock('../../ssh/orcad-runtime-maintenance', async (importOriginal) => ({ + ...(await importOriginal()), + withManagedOrcadLifecycle: ( + _path: string, + _selector: string, + run: (managed: unknown) => Promise + ) => run({ environment: { id: 'env-1' }, deployment: { remotePort: 7777 } }) +})) +vi.mock('./orcad-managed-runtime-context', () => ({ + resolveLinkedOrcadContext: async () => ({ host: mocks.host.current }), + managedOrcadSlot: () => ({ + host: mocks.host.current, + remoteHome: mocks.home.current, + userDataDir: '/home/dev/.orca' + }) +})) +vi.mock('../activation/orcad-activation-lock', () => ({ + withOrcadActivationLock: (_slot: unknown, run: () => Promise) => run() +})) +vi.mock('../activation/orcad-activation-record-store', () => ({ + readOrcadActivationRecord: async () => ({ active: '1.0.0' }) +})) +vi.mock('./orcad-managed-wake', () => ({ wakeStoppedManagedOrcad: mocks.wake })) +vi.mock('./orcad-managed-tunnel', () => ({ rebuildOrcadManagedTunnel: mocks.rebuild })) +vi.mock('../install/orcad-remote-runtime-control', () => ({ execOrcadRemote: mocks.exec })) + +const { runManagedOrcadStaleLockReclaim } = await import('./orcad-managed-stale-lock-reclaim') + +const evidence = { + pid: 41, + host: 'box', + bootId: 'b1', + currentBootId: 'b2', + ageMinutes: 90, + nonce: 'n-1' +} +// What orcad adds to a status line beyond the fields every answer carries. +type AnswerExtras = { exitCode?: number; evidence?: typeof evidence } + +const answer = (status: string, extra: AnswerExtras = {}) => + JSON.stringify({ status, lockPath: '/home/dev/.orca/orcad.lock', message: status, ...extra }) + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +beforeEach(() => { + vi.clearAllMocks() + mocks.home.current = '/home/dev' + mocks.host.current = getRemoteHostPlatform('linux-x64') + mocks.wake.mockResolvedValue({ outcome: 'started', readiness: {} }) + mocks.rebuild.mockResolvedValue(undefined) +}) + +describe('runManagedOrcadStaleLockReclaim', () => { + it('reads the dry run’s evidence from the command’s exit-2 output', async () => { + mocks.exec.mockRejectedValue( + sshCommandExitError('orcad', 2, `${answer('eligible', { exitCode: 2, evidence })}\n`) + ) + await expect(runManagedOrcadStaleLockReclaim('/u', 'env-1', { yes: false })).resolves.toEqual({ + status: 'eligible', + lockPath: '/home/dev/.orca/orcad.lock', + message: 'eligible', + evidence + }) + const command: string = mocks.exec.mock.calls[0][1] + expect(command).toContain("--reclaim-stale-lock --data-root '/home/dev/.orca' --json") + expect(command).not.toContain('--yes') + // Only the slot gate names the pin; a dry run passes no nonce to orcad. + expect(command.split('--expect-nonce')).toHaveLength(2) + expect(mocks.wake).not.toHaveBeenCalled() + }) + + it('never guesses from an orcad that answers without JSON', async () => { + mocks.exec.mockRejectedValue(sshCommandExitError('orcad', 1, 'Error: lock held')) + const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', { + yes: true, + expectNonce: 'n-1' + }) + // The slot supports the command, so nothing here argues for deleting the lock by hand. + expect(result).toEqual({ status: 'no-answer', message: expect.any(String) }) + expect(mocks.wake).not.toHaveBeenCalled() + }) + + it('keeps the reclaimed result when the wake throws after the lock is gone', async () => { + mocks.wake.mockRejectedValue(new Error('readiness timed out')) + mocks.exec.mockResolvedValue(`${answer('reclaimed', { exitCode: 0, evidence })}\n`) + const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', { + yes: true, + expectNonce: 'n-1' + }) + expect(result).toMatchObject({ + status: 'reclaimed', + environmentId: 'env-1', + evidence, + notRestarted: expect.stringContaining('readiness timed out') + }) + }) + + it('reports a started server as restarted even when its forward could not follow it', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.rebuild.mockRejectedValue(new Error('forward refused')) + mocks.exec.mockResolvedValue(`${answer('reclaimed', { exitCode: 0, evidence })}\n`) + const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', { + yes: true, + expectNonce: 'n-1' + }) + expect(result).toMatchObject({ status: 'reclaimed', evidence }) + expect(result).not.toHaveProperty('notRestarted') + }) + + it('reads a status a newer orcad added as no answer', async () => { + mocks.exec.mockRejectedValue(sshCommandExitError('orcad', 5, `${answer('deferred')}\n`)) + const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', { yes: false }) + expect(result.status).toBe('no-answer') + }) + + it('names the lock to delete by hand only for a slot without the command', async () => { + mocks.exec.mockResolvedValue('ORCA_STALE_LOCK_RECLAIM_UNSUPPORTED\n') + const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', { yes: false }) + expect(result).toMatchObject({ status: 'unsupported', lockPath: '/home/dev/.orca/orcad.lock' }) + }) + + it('runs nothing on a host whose starts never refuse a shared-home lock', async () => { + mocks.host.current = getRemoteHostPlatform('darwin-arm64') + const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', { + yes: true, + expectNonce: 'n-1' + }) + expect(result.status).toBe('unsupported') + expect(mocks.exec).not.toHaveBeenCalled() + }) + + it('starts the server again once the confirmed reclaim cleared the lock', async () => { + mocks.exec.mockResolvedValue(`${answer('reclaimed', { exitCode: 0, evidence })}\n`) + const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', { + yes: true, + expectNonce: 'n-1' + }) + expect(mocks.exec.mock.calls[0][1]).toContain("--json --expect-nonce 'n-1' --yes") + expect(result).toMatchObject({ status: 'reclaimed', environmentId: 'env-1', evidence }) + expect(result).not.toHaveProperty('notRestarted') + expect(mocks.wake).toHaveBeenCalledOnce() + expect(mocks.rebuild).toHaveBeenCalledWith('/u', 'env-1') + }) + + // Runs the real command in a shell against a slot whose runtime records any invocation. + // The fake runtime prints nothing, which a slot that runs the command reports as no answer. + it.skipIf(process.platform === 'win32').each([ + // A build with the reclaim but not the nonce pin must never be confirmed from here. + [ + 'never starts an orcad that has the reclaim but not the pin', + 'argv[2] === "--reclaim-stale-lock"', + false, + { status: 'unsupported', reclaimWithoutPin: true } + ], + [ + 'never starts an orcad without the reclaim', + 'orcad server', + false, + { status: 'unsupported', lockPath: expect.stringContaining('orcad.lock') } + ], + [ + 'runs the slot that names the pin', + '"--reclaim-stale-lock" "--expect-nonce"', + true, + { status: 'no-answer' } + ] + ])('%s', async (_case, bundle, invoked, expected) => { + const home = mkdtempSync(join(tmpdir(), 'orcad-reclaim-gate-')) + roots.push(home) + mocks.home.current = home + const slotDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, home, '1.0.0') + mkdirSync(slotDir, { recursive: true }) + writeFileSync(join(slotDir, 'orcad-server.js'), bundle) + writeFileSync(join(slotDir, '.build-target'), 'linux-x64') + const runtime = join(slotDir, 'bun-runtime') + writeFileSync(runtime, `#!/bin/sh\ntouch ${quotePosixShell(join(home, 'invoked'))}\n`) + chmodSync(runtime, 0o755) + mocks.exec.mockImplementation(async (_slot: unknown, command: string) => { + const { stdout } = await runProcess({ program: '/bin/sh', args: ['-c', command] }) + return stdout + }) + const result = await runManagedOrcadStaleLockReclaim('/u', 'env-1', { + yes: true, + expectNonce: 'n-1' + }) + expect(result).toMatchObject(expected) + expect(existsSync(join(home, 'invoked'))).toBe(invoked) + }) +}) diff --git a/src/main/managed-server/lifecycle/orcad-managed-stale-lock-reclaim.ts b/src/main/managed-server/lifecycle/orcad-managed-stale-lock-reclaim.ts new file mode 100644 index 00000000000..f5cd0189b41 --- /dev/null +++ b/src/main/managed-server/lifecycle/orcad-managed-stale-lock-reclaim.ts @@ -0,0 +1,211 @@ +/** + * Runs `orcad --reclaim-stale-lock` from a managed host's active slot, for a start refused because + * its instance lock may belong to another machine sharing the home (#32177). The slot's own build + * gathers the evidence and clears the lock; this client only relays the user's confirmation. + */ +import { z } from 'zod' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' +import { + ORCAD_RECLAIM_STALE_LOCK_FLAG, + ORCAD_STALE_LOCK_DATA_ROOT_FLAG, + ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG +} from '../../../shared/orcad-stale-lock-reclaim' +import type { + OrcadManagedStaleLockReclaimRequest, + OrcadManagedStaleLockReclaimResult +} from '../../../shared/orcad-managed-runtime' +import { + ORCAD_LAUNCHER_FILENAME, + ORCAD_SERVER_ENTRY_FILENAME +} from '../../../shared/orcad-artifacts' +import { withOrcadActivationLock } from '../activation/orcad-activation-lock' +import { readOrcadActivationRecord } from '../activation/orcad-activation-record-store' +import { + followLaunchedServerPort, + withManagedOrcadLifecycle +} from '../../ssh/orcad-runtime-maintenance' +import { managedOrcadSlot, resolveLinkedOrcadContext } from './orcad-managed-runtime-context' +import { selectOrcadSlotRuntimeCommand } from '../install/orcad-remote-runtime' +import { execOrcadRemote } from '../install/orcad-remote-runtime-control' +import { orcadSlotDir, type OrcadSlotOptions } from '../../ssh/orcad-recovery-slot' +import { joinRemotePath } from '../../ssh/ssh-remote-platform' +import { isSshCommandExitError } from '../../ssh/remote-exec/remote-exec-command' +import { wakeStoppedManagedOrcad } from './orcad-managed-wake' +import { ORCAD_LOCK_FILE_NAME } from '../../orcad/orcad-instance-lock' +import { errorMessage } from '../../../shared/error-message' + +const ReclaimStatusSchema = z.enum(['none', 'eligible', 'refused', 'reclaimed']) + +// Why a string: a newer orcad's status must read as no answer, not as an unparseable reply. +const ReclaimAnswerSchema = z.object({ + status: z.string(), + lockPath: z.string(), + message: z.string(), + evidence: z + .object({ + pid: z.number(), + host: z.string(), + bootId: z.string(), + currentBootId: z.string(), + ageMinutes: z.number(), + nonce: z.string().optional() + }) + .optional() +}) + +const RECLAIM_UNSUPPORTED_SENTINEL = 'ORCA_STALE_LOCK_RECLAIM_UNSUPPORTED' +const RECLAIM_UNPINNED_SENTINEL = 'ORCA_STALE_LOCK_RECLAIM_UNPINNED' + +type ReclaimAnswer = Omit, 'status'> & { + status: z.infer +} + +type SlotReclaim = + | { status: 'answered'; answer: ReclaimAnswer } + /** `unpinned`: the slot has the reclaim but not `--expect-nonce`, so only a person may run it. */ + | { status: 'unsupported' | 'unpinned' | 'no-answer' | 'busy'; message: string } + +export function runManagedOrcadStaleLockReclaim( + userDataPath: string, + selector: string, + request: OrcadManagedStaleLockReclaimRequest +): Promise { + return withManagedOrcadLifecycle(userDataPath, selector, async ({ environment, deployment }) => { + const context = await resolveLinkedOrcadContext(environment, deployment) + // Only Linux starts refuse a lock another machine may hold; elsewhere the slot has no such lock. + if (context.host.os !== 'linux') { + return unsupported('This host’s Orca server has no stale-lock recovery.') + } + const slot = managedOrcadSlot(context, deployment.remotePort) + // Serialized with launches, so a wake never reads the lock while it is being cleared. + const ran = await withOrcadActivationLock( + slot, + () => runOnActiveSlot(slot, request), + (): SlotReclaim => ({ + status: 'busy', + message: 'An update, rollback or recovery holds this host; try again when it finishes.' + }) + ) + if (ran.status === 'unsupported') { + const lockPath = joinRemotePath(slot.host, slot.userDataDir, ORCAD_LOCK_FILE_NAME) + return { status: 'unsupported', message: ran.message, lockPath } + } + if (ran.status === 'unpinned') { + return { status: 'unsupported', message: ran.message, reclaimWithoutPin: true } + } + if (ran.status !== 'answered') { + return { status: ran.status, message: ran.message } + } + const { answer } = ran + if (answer.status !== 'reclaimed') { + return { ...answer, status: answer.status } + } + const reclaimed = { + status: 'reclaimed', + environmentId: environment.id, + message: answer.message, + lockPath: answer.lockPath, + ...(answer.evidence ? { evidence: answer.evidence } : {}) + } as const + const notRestarted = await restartAfterReclaim(userDataPath, environment.id, slot) + return notRestarted ? { ...reclaimed, notRestarted } : reclaimed + }) +} + +/** Why the server did not start again, or null; never throws, since the lock is already gone. */ +async function restartAfterReclaim( + userDataPath: string, + environmentId: string, + slot: OrcadSlotOptions +): Promise { + try { + const wake = await wakeStoppedManagedOrcad(slot) + if (wake.outcome !== 'started' && wake.outcome !== 'serving') { + return wake.outcome === 'recovery-refused' + ? wake.reason + : `The lock was cleared, but this host’s Orca server did not start (${wake.outcome}); reconnect to start it.` + } + // The server runs now; a forward that fails to follow it is the next connect's to rebuild. + await followLaunchedServerPort(userDataPath, environmentId) + return null + } catch (error) { + // A lost result would leave the caller's reconnect and note clearing undone. + return `The lock was cleared, but this host’s Orca server did not start: ${errorMessage(error)}` + } +} + +async function runOnActiveSlot( + slot: OrcadSlotOptions, + request: OrcadManagedStaleLockReclaimRequest +): Promise { + // Re-read under the fence: another client may have changed the active slot. + const active = (await readOrcadActivationRecord(slot)).active + if (!active) { + return { status: 'unsupported', message: 'This host has no activated Orca server.' } + } + const slotDir = orcadSlotDir(slot, active) + const entry = joinRemotePath(slot.host, slotDir, ORCAD_LAUNCHER_FILENAME) + const bundle = joinRemotePath(slot.host, slotDir, ORCAD_SERVER_ENTRY_FILENAME) + // The confirmation clears only the record the user approved, never one written since. + const confirm = request.yes + ? ` ${ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG} ${quotePosixShell(request.expectNonce)} --yes` + : '' + // Why first: an orcad without the flag would ignore it and start serving in the foreground, and + // one without the nonce pin could clear a lock written after the check. + const command = + `if grep -qF -- ${quotePosixShell(ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG)} ${quotePosixShell(bundle)} 2>/dev/null; then ` + + `${selectOrcadSlotRuntimeCommand(slot.host, slotDir, '')} && ` + + `"$orcad_runtime" ${quotePosixShell(entry)} ${ORCAD_RECLAIM_STALE_LOCK_FLAG} ` + + `${ORCAD_STALE_LOCK_DATA_ROOT_FLAG} ${quotePosixShell(slot.userDataDir)} --json${confirm}; ` + + `elif grep -qF -- ${quotePosixShell(ORCAD_RECLAIM_STALE_LOCK_FLAG)} ${quotePosixShell(bundle)} 2>/dev/null; then ` + + `echo ${RECLAIM_UNPINNED_SENTINEL}; else echo ${RECLAIM_UNSUPPORTED_SENTINEL}; fi` + let output: string + try { + output = await execOrcadRemote(slot, command) + } catch (error) { + // Every status but `reclaimed` exits non-zero and still prints its answer. + if (!isSshCommandExitError(error)) { + throw error + } + output = error.stdout + } + const lines = output.split('\n').map((line) => line.trim()) + if (lines.includes(RECLAIM_UNSUPPORTED_SENTINEL)) { + return unsupported('This host’s Orca server predates the stale-lock recovery.') + } + if (lines.includes(RECLAIM_UNPINNED_SENTINEL)) { + return { + status: 'unpinned', + message: 'This host’s Orca server is too old for Orca to clear the lock from here.' + } + } + const answer = parseReclaimAnswer(output) + // The slot supports the command, so this is no reason to delete the lock by hand. + return answer + ? { status: 'answered', answer } + : { + status: 'no-answer', + message: 'This host’s Orca server gave no answer Orca understands to the stale-lock check.' + } +} + +function parseReclaimAnswer(output: string): ReclaimAnswer | null { + const line = output + .trim() + .split('\n') + .findLast((candidate) => candidate.trim().startsWith('{')) + if (!line) { + return null + } + try { + const parsed = ReclaimAnswerSchema.safeParse(JSON.parse(line)) + const status = ReclaimStatusSchema.safeParse(parsed.data?.status) + return parsed.success && status.success ? { ...parsed.data, status: status.data } : null + } catch { + return null + } +} + +function unsupported(message: string): { status: 'unsupported'; message: string } { + return { status: 'unsupported', message } +} diff --git a/src/main/orcad/orcad-stale-lock-reclaim.test.ts b/src/main/orcad/orcad-stale-lock-reclaim.test.ts index eb357eba2a3..5f7f1a5acf4 100644 --- a/src/main/orcad/orcad-stale-lock-reclaim.test.ts +++ b/src/main/orcad/orcad-stale-lock-reclaim.test.ts @@ -97,6 +97,28 @@ describe.skipIf(process.platform === 'win32')('reclaimStaleOrcadInstanceLock (#3 expect(existsSync(lockPath)).toBe(false) }) + it('reclaims under --expect-nonce only the record the dry run showed', async () => { + const root = makeRoot() + const lockPath = writeLock(root, holderRecord()) + const changed = await reclaimStaleOrcadInstanceLock( + root, + { yes: true, expectNonce: 'approved-earlier', hintRuntime: null }, + rebootedBox() + ) + expect(changed).toMatchObject({ + status: 'refused', + message: 'The lock changed after you checked it. Check it again.' + }) + expect(existsSync(lockPath)).toBe(true) + const pinned = await reclaimStaleOrcadInstanceLock( + root, + { yes: true, expectNonce: 'stale-holder', hintRuntime: null }, + rebootedBox() + ) + expect(pinned).toMatchObject({ status: 'reclaimed', evidence: { nonce: 'stale-holder' } }) + expect(existsSync(lockPath)).toBe(false) + }) + it('never reports an empty data root as a recovery', async () => { const result = await reclaimStaleOrcadInstanceLock( makeRoot(), diff --git a/src/main/orcad/orcad-stale-lock-reclaim.ts b/src/main/orcad/orcad-stale-lock-reclaim.ts index cec8c59aeee..3a81dd905be 100644 --- a/src/main/orcad/orcad-stale-lock-reclaim.ts +++ b/src/main/orcad/orcad-stale-lock-reclaim.ts @@ -32,8 +32,10 @@ import { RUNTIME_SOCKET_NAME_REGEX } from '../runtime/runtime-rpc/runtime-rpc-so import { ORCAD_RECLAIM_STALE_LOCK_FLAG, ORCAD_STALE_LOCK_DATA_ROOT_FLAG, + ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG, ORCAD_STALE_LOCK_RECLAIM_EXIT_CODES, type OrcadReclaimHintRuntime, + type OrcadStaleLockEvidence, type OrcadStaleLockReclaimStatus } from '../../shared/orcad-stale-lock-reclaim' @@ -54,14 +56,6 @@ export type OrcadStaleLockReclaimHooks = { lockHooks?: OrcadInstanceLockHooks } -export type OrcadStaleLockEvidence = { - pid: number - host: string - bootId: string - currentBootId: string - ageMinutes: number -} - export type OrcadStaleLockReclaimResult = { status: OrcadStaleLockReclaimStatus exitCode: number @@ -74,6 +68,8 @@ export async function reclaimStaleOrcadInstanceLock( dataRoot: string, options: { yes: boolean + /** Refuse unless the lock still holds this nonce: the record the user approved. */ + expectNonce?: string /** Who to name in a refusal's command: an orcad names its own runtime, the CLI `orca serve`. */ hintRuntime: OrcadReclaimHintRuntime | null }, @@ -106,6 +102,9 @@ export async function reclaimStaleOrcadInstanceLock( if (!holder) { return result('refused', `The lock ${lockPath} is not a complete record. Check it by hand.`) } + if (options.expectNonce !== undefined && holder.nonce !== options.expectNonce) { + return result('refused', 'The lock changed after you checked it. Check it again.') + } const currentBoot = (hooks.bootId ?? currentBootId)() const checked = await checkStaleLock(dataRoot, lockPath, holder, currentBoot, hooks) if ('refusal' in checked) { @@ -205,7 +204,8 @@ async function checkStaleLock( host: holder.host, bootId: holder.bootId, currentBootId: bootId, - ageMinutes: Math.round((now - Date.parse(holder.acquiredAt)) / 60_000) + ageMinutes: Math.round((now - Date.parse(holder.acquiredAt)) / 60_000), + nonce: holder.nonce } } } @@ -291,26 +291,34 @@ export function printOrcadStaleLockReclaimResult( ;(result.status === 'reclaimed' ? console.log : console.error)(result.message) } -/** `orcad --reclaim-stale-lock [--data-root ] [--yes] [--json]`. */ +/** `orcad --reclaim-stale-lock [--data-root ] [--expect-nonce ] [--yes] [--json]`. */ export async function runOrcadStaleLockReclaimCommandAndExit(argv: string[]): Promise { const args = argv.slice(1) const json = args.includes('--json') - const dataRootIndex = args.indexOf(ORCAD_STALE_LOCK_DATA_ROOT_FLAG) - const dataRoot = dataRootIndex === -1 ? resolveUserDataPath() : args[dataRootIndex + 1] + const valueFlags = [ORCAD_STALE_LOCK_DATA_ROOT_FLAG, ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG] + const valueIndexes = valueFlags.map((flag) => args.indexOf(flag)).filter((index) => index !== -1) + const valueOf = (flag: string): string | undefined => { + const index = args.indexOf(flag) + return index === -1 ? undefined : (args[index + 1] ?? '') + } + const dataRoot = valueOf(ORCAD_STALE_LOCK_DATA_ROOT_FLAG) ?? resolveUserDataPath() + const expectNonce = valueOf(ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG) const unknown = args.filter( (arg, index) => - !['--yes', '--json', ORCAD_STALE_LOCK_DATA_ROOT_FLAG].includes(arg) && - (dataRootIndex === -1 || index !== dataRootIndex + 1) + !['--yes', '--json', ...valueFlags].includes(arg) && !valueIndexes.includes(index - 1) ) - // A missing value must never fall through to another data root, nor read a flag as a path. - if (!dataRoot?.trim() || dataRoot.startsWith('-') || unknown.length) { + // A missing value must never fall through to another data root or nonce, nor read a flag as one. + const badValue = (value: string | undefined): boolean => + value !== undefined && (!value.trim() || value.startsWith('-')) + if (!dataRoot || badValue(dataRoot) || badValue(expectNonce) || unknown.length) { console.error( - `Usage: orcad ${ORCAD_RECLAIM_STALE_LOCK_FLAG} [${ORCAD_STALE_LOCK_DATA_ROOT_FLAG} ] [--yes] [--json]` + `Usage: orcad ${ORCAD_RECLAIM_STALE_LOCK_FLAG} [${ORCAD_STALE_LOCK_DATA_ROOT_FLAG} ] [${ORCAD_STALE_LOCK_EXPECT_NONCE_FLAG} ] [--yes] [--json]` ) process.exit(ORCAD_STALE_LOCK_RECLAIM_EXIT_CODES.refused) } const result = await reclaimStaleOrcadInstanceLock(dataRoot, { yes: args.includes('--yes'), + ...(expectNonce !== undefined ? { expectNonce } : {}), hintRuntime: { execPath: process.execPath, entry: process.argv[1] } }) printOrcadStaleLockReclaimResult(result, json) diff --git a/src/main/ssh/orcad-launch-ssh-session.test.ts b/src/main/ssh/orcad-launch-ssh-session.test.ts index 199bb608bfa..3dc88227b3e 100644 --- a/src/main/ssh/orcad-launch-ssh-session.test.ts +++ b/src/main/ssh/orcad-launch-ssh-session.test.ts @@ -261,6 +261,8 @@ describe('orcad launch command session record', () => { }) it('still prints the detached PID last', () => { - expect(orcadLaunchCommand(mac, spec)).toMatch(/&& cat '[^']+\/\.orcad-pid'$/u) + const command = orcadLaunchCommand(mac, spec) + expect(command).toMatch(/^orcad_pid='[^']+\/\.orcad-pid';/u) + expect(command).toMatch(/&& cat "\$orcad_pid"$/u) }) }) diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts index 1804a0fa2bd..09d3e726104 100644 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ b/src/main/ssh/orcad-runtime-maintenance.ts @@ -82,7 +82,7 @@ function refreshPairing( } /** A launched slot may bind another port than the forward in use, so forward anew. */ -async function followLaunchedServerPort( +export async function followLaunchedServerPort( userDataPath: string, environmentId: string ): Promise { diff --git a/src/preload/api/managed-orcad-api.ts b/src/preload/api/managed-orcad-api.ts index 515aa642d75..de78a069312 100644 --- a/src/preload/api/managed-orcad-api.ts +++ b/src/preload/api/managed-orcad-api.ts @@ -10,6 +10,8 @@ import type { OrcadManagedPendingMigrationRow, OrcadManagedRecoveryResult, OrcadManagedRestartResult, + OrcadManagedStaleLockReclaimRequest, + OrcadManagedStaleLockReclaimResult, OrcadManagedRollbackResult, OrcadManagedRuntimeStatus, OrcadManagedStopResult @@ -36,6 +38,10 @@ export type ManagedOrcadPreloadApi = { }) => Promise /** Restarts from this client's SSH session; refused while terminals run. */ restart: (args: { selector: string }) => Promise + /** Without `yes`, only reports whether the host's instance lock may be cleared. */ + reclaimStaleLock: ( + args: { selector: string } & OrcadManagedStaleLockReclaimRequest + ) => Promise stop: (args: { selector: string }) => Promise cancelStop: (args: { selector: string }) => Promise forget: (args: { selector: string }) => Promise @@ -60,6 +66,7 @@ export const managedOrcadApi: ManagedOrcadPreloadApi = { rollback: (args) => ipcRenderer.invoke('runtimeEnvironments:rollbackOrcad', args), recover: (args) => ipcRenderer.invoke('runtimeEnvironments:recoverOrcad', args), restart: (args) => ipcRenderer.invoke('runtimeEnvironments:restartOrcad', args), + reclaimStaleLock: (args) => ipcRenderer.invoke('runtimeEnvironments:reclaimOrcadStaleLock', args), stop: (args) => ipcRenderer.invoke('runtimeEnvironments:stopOrcad', args), cancelStop: (args) => ipcRenderer.invoke('runtimeEnvironments:cancelOrcadStop', args), forget: (args) => ipcRenderer.invoke('runtimeEnvironments:forgetOrcad', args), diff --git a/src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx b/src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx index 2dd64ecee68..eac8bee5e64 100644 --- a/src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx +++ b/src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx @@ -56,6 +56,7 @@ function api(next: OrcadDeltaMovePreview, result: OrcadDeltaMoveResult): Managed rollback: unused, recover: unused, restart: unused, + reclaimStaleLock: unused, stop: unused, cancelStop: unused, forget: unused, diff --git a/src/renderer/src/components/settings/SshHostRecoverServerAction.test.tsx b/src/renderer/src/components/settings/SshHostRecoverServerAction.test.tsx new file mode 100644 index 00000000000..9ffb514588a --- /dev/null +++ b/src/renderer/src/components/settings/SshHostRecoverServerAction.test.tsx @@ -0,0 +1,181 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcadManagedStaleLockReclaimResult } from '../../../../shared/orcad-managed-runtime' +import type { SshManagedServerStatus } from '../../../../shared/ssh-types' +import { + canRecoverSshHostServer, + SshHostRecoverServerDialog, + staleLockReclaimCommandFrom +} from './SshHostRecoverServerAction' + +vi.mock('../ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
+})) + +const COMMAND = '/opt/bun /slot/orcad.js --reclaim-stale-lock --data-root /home/dev/.orca' +const DETAIL = `orcad holds the data root. If it is not running anywhere, run \`${COMMAND}\` on the machine it ran on.` +const refusedStart: SshManagedServerStatus = { + kind: 'managed', + environmentId: 'env-1', + serving: { state: 'unverifiable', detail: DETAIL } +} + +const eligible: OrcadManagedStaleLockReclaimResult = { + status: 'eligible', + lockPath: '/home/dev/.orca/orcad.lock', + message: 'eligible', + evidence: { + pid: 41, + host: 'box', + bootId: 'boot-old', + currentBootId: 'boot-new', + ageMinutes: 90, + nonce: 'n-approved' + } +} +const reclaimStaleLock = vi.fn( + async (args: { yes: boolean }): Promise => + args.yes + ? { status: 'reclaimed', environmentId: 'env-1', message: 'cleared', lockPath: '/l' } + : eligible +) +const roots: Root[] = [] +beforeEach(() => { + reclaimStaleLock.mockClear() + Object.assign(window, { api: { runtimeEnvironments: { managedOrcad: { reclaimStaleLock } } } }) +}) +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + document.body.innerHTML = '' +}) + +async function render(onOpenChange = vi.fn(), onChanged = vi.fn()) { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + roots.push(root) + const show = (status: SshManagedServerStatus) => + act(async () => { + root.render( + + ) + }) + await show(refusedStart) + return Object.assign(container, { show }) +} + +function button(container: HTMLElement, text: string): HTMLButtonElement { + return [...container.querySelectorAll('button')].find((entry) => entry.textContent === text)! +} + +describe('Recover server', () => { + it('is offered only when the slot’s own refusal named the command', () => { + expect(canRecoverSshHostServer(refusedStart)).toBe(true) + expect( + canRecoverSshHostServer({ + kind: 'managed', + environmentId: 'env-1', + serving: { state: 'unverifiable', detail: 'orcad holds the data root.' } + }) + ).toBe(false) + }) + + it('is not offered on the web client, which has no managed-server API', () => { + Object.assign(window, { api: { runtimeEnvironments: {} } }) + expect(canRecoverSshHostServer(refusedStart)).toBe(false) + }) + + it('names the refusal’s command for a manual recovery', () => { + expect(staleLockReclaimCommandFrom(DETAIL)).toBe(COMMAND) + }) + + it('clears the lock only after the user rules out another machine', async () => { + const onOpenChange = vi.fn() + const onChanged = vi.fn() + const container = await render(onOpenChange, onChanged) + expect(reclaimStaleLock).toHaveBeenCalledWith({ selector: 'env-1', yes: false }) + expect(container.textContent).toContain('boot-old') + const confirm = button(container, 'Clear lock and restart') + expect(confirm.disabled).toBe(true) + await act(async () => container.querySelector('[role="checkbox"]')!.click()) + expect(confirm.disabled).toBe(false) + expect(reclaimStaleLock).toHaveBeenCalledTimes(1) + await act(async () => confirm.click()) + expect(reclaimStaleLock).toHaveBeenLastCalledWith({ + selector: 'env-1', + yes: true, + expectNonce: 'n-approved' + }) + expect(onChanged).toHaveBeenCalled() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + // The reclaim clears the note that offered it, so the status no longer passes the gate. + it('stays open with its result once the status that offered it is gone', async () => { + reclaimStaleLock.mockResolvedValueOnce(eligible).mockResolvedValueOnce({ + status: 'reclaimed', + environmentId: 'env-1', + message: 'cleared', + lockPath: '/l', + notRestarted: 'did not start' + }) + const onOpenChange = vi.fn() + const container = await render(onOpenChange) + await container.show({ kind: 'managed', environmentId: 'env-1' }) + expect(container.textContent).toContain('boot-old') + await act(async () => container.querySelector('[role="checkbox"]')!.click()) + await act(async () => button(container, 'Clear lock and restart').click()) + expect(container.textContent).toContain('did not start') + expect(onOpenChange).not.toHaveBeenCalled() + // The lock is gone: no stale evidence, no second confirmation, no manual recovery. + expect(container.textContent).not.toContain('boot-old') + expect(button(container, 'Clear lock and restart')).toBeUndefined() + expect(container.textContent).not.toContain('--reclaim-stale-lock') + expect(container.textContent).not.toContain('rm /') + expect(button(container, 'Close')).toBeDefined() + }) + + it('offers only a delete by hand when the host can’t run the check', async () => { + reclaimStaleLock.mockResolvedValueOnce({ + status: 'unsupported', + message: 'no answer', + lockPath: '/home/dev/.orca/orcad.lock' + }) + const container = await render() + expect(container.textContent).toContain('no answer') + expect(container.textContent).toContain('rm /home/dev/.orca/orcad.lock') + expect(container.textContent).not.toContain('--reclaim-stale-lock') + expect(button(container, 'Clear lock and restart')).toBeUndefined() + }) + + it.each([ + { + status: 'refused', + message: 'still running', + lockPath: '/home/dev/.orca/orcad.lock' + } as const, + { status: 'no-answer', message: 'no answer understood' } as const, + { status: 'unsupported', message: 'too old to pin', reclaimWithoutPin: true } as const + ])('names the reclaim command, never a delete, for $status', async (result) => { + reclaimStaleLock.mockResolvedValueOnce(result) + const container = await render() + expect(container.textContent).toContain(COMMAND) + expect(container.textContent).not.toContain('rm /home') + }) +}) diff --git a/src/renderer/src/components/settings/SshHostRecoverServerAction.tsx b/src/renderer/src/components/settings/SshHostRecoverServerAction.tsx new file mode 100644 index 00000000000..40ed0b53ac0 --- /dev/null +++ b/src/renderer/src/components/settings/SshHostRecoverServerAction.tsx @@ -0,0 +1,362 @@ +import { Loader2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import type { OrcadManagedStaleLockReclaimResult } from '../../../../shared/orcad-managed-runtime' +import { + ORCAD_RECLAIM_STALE_LOCK_FLAG, + type OrcadStaleLockEvidence +} from '../../../../shared/orcad-stale-lock-reclaim' +import type { SshManagedServerStatus } from '../../../../shared/ssh-types' +import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api' +import { quotePosixShellUnlessPlain } from '../../../../shared/shell-argument-quoting' +import { useMountedRef } from '@/hooks/useMountedRef' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { Checkbox } from '../ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' +import { Label } from '../ui/label' +import { getManagedOrcadApi } from './use-managed-server-catalog' + +/** Offered only when the slot's own refusal named the command, so that build supports it. */ +export function canRecoverSshHostServer(status: SshManagedServerStatus | undefined): boolean { + return Boolean( + getManagedOrcadApi() && + status?.kind === 'managed' && + status.serving?.state === 'unverifiable' && + status.serving.detail.includes(ORCAD_RECLAIM_STALE_LOCK_FLAG) + ) +} + +/** The command the refusal names, for the user to run on the host by hand. */ +export function staleLockReclaimCommandFrom(detail: string): string | null { + return ( + detail + .match(/`([^`]*)`/g) + ?.find((quoted) => quoted.includes(ORCAD_RECLAIM_STALE_LOCK_FLAG)) + ?.slice(1, -1) ?? null + ) +} + +/** + * Clears a managed server's instance lock that a start refused because another machine sharing + * the home may hold it (#32177). The host checks first; only the user can rule that machine out. + */ +export function SshHostRecoverServerDialog({ + status, + open, + onOpenChange, + onChanged +}: { + status: SshManagedServerStatus | undefined + open: boolean + onOpenChange: (open: boolean) => void + onChanged: () => void +}): React.JSX.Element | null { + const api = getManagedOrcadApi() + if (!api || !open) { + return null + } + // Mounted per opening, so each one starts from a fresh check. + return ( + + ) +} + +function OpenRecoverServerDialog({ + api, + status, + onOpenChange, + onChanged +}: { + api: ManagedOrcadPreloadApi + status: SshManagedServerStatus | undefined + onOpenChange: (open: boolean) => void + onChanged: () => void +}): React.JSX.Element | null { + // Frozen at opening: the reclaim itself clears the note that offered it, and a later status + // push must neither close the dialog nor hide the result the user is reading. + const [opened] = useState(() => + status?.kind === 'managed' + ? { environmentId: status.environmentId, detail: status.serving?.detail ?? '' } + : null + ) + const mountedRef = useMountedRef() + const [check, setCheck] = useState(null) + const [confirmed, setConfirmed] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!opened) { + return + } + api.reclaimStaleLock({ selector: opened.environmentId, yes: false }).then( + (next) => mountedRef.current && setCheck(next), + (cause: unknown) => mountedRef.current && setError(messageOf(cause)) + ) + }, [api, mountedRef, opened]) + + if (!opened) { + return null + } + const { environmentId, detail } = opened + const reclaim = async (expectNonce: string): Promise => { + setBusy(true) + setError(null) + try { + const result = await api.reclaimStaleLock({ selector: environmentId, yes: true, expectNonce }) + if (!mountedRef.current) { + return + } + if (result.status === 'reclaimed') { + onChanged() + if (!result.notRestarted) { + onOpenChange(false) + return + } + } + // A cleared lock ends the flow: its evidence and the confirmation no longer apply. + setCheck(result) + } catch (cause) { + if (mountedRef.current) { + setError(messageOf(cause)) + } + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + } + const eligible = check?.status === 'eligible' + // The confirmation is pinned to the record the user saw; without its nonce there is none to pin. + const approvedNonce = eligible ? check.evidence?.nonce : undefined + return ( + !next && !busy && onOpenChange(false)}> + + + + {translate('auto.components.settings.recoverServer.title', 'Recover Orca’s server')} + + + {translate( + 'auto.components.settings.recoverServer.description', + 'Orca’s server on this host won’t start because a lock from an earlier run is still in place.' + )} + + + {!check && !error ? ( +

+ + {translate('auto.components.settings.recoverServer.checking', 'Checking the lock…')} +

+ ) : null} + {eligible ? : null} + {eligible ? ( +
+ setConfirmed(next === true)} + /> + +
+ ) : null} + {check && !eligible ? : null} + {error ? ( +
+

{error}

+ +
+ ) : null} + + + {eligible ? ( + + ) : null} + +
+
+ ) +} + +function EligibleLock({ + evidence +}: { + evidence: OrcadStaleLockEvidence | undefined +}): React.JSX.Element { + return ( +
+

+ {translate( + 'auto.components.settings.recoverServer.eligible', + 'Nothing answers on this machine at its old address, but Orca can’t rule out another machine with the same name that shares this home folder.' + )} +

+ {evidence ? ( +
+
+ {translate('auto.components.settings.recoverServer.host', 'Host')} +
+
{evidence.host}
+
+ {translate('auto.components.settings.recoverServer.lockBoot', 'Written during boot')} +
+
{evidence.bootId}
+
+ {translate('auto.components.settings.recoverServer.currentBoot', 'Current boot')} +
+
{evidence.currentBootId}
+
+ {translate('auto.components.settings.recoverServer.written', 'Written')} +
+
{formatUiRelativeTime(-evidence.ageMinutes * 60_000)}
+
+ ) : null} +

+ {translate( + 'auto.components.settings.recoverServer.warning', + 'Only continue if no other machine sharing this home runs Orca for this profile.' + )} +

+
+ ) +} + +function CheckOutcome({ + check, + detail +}: { + check: OrcadManagedStaleLockReclaimResult + detail: string +}): React.JSX.Element { + if (check.status === 'reclaimed') { + // The lock is gone, so neither the reclaim command nor a delete by hand applies. + return ( +
+

+ {translate( + 'auto.components.settings.recoverServer.reclaimedNotStarted', + 'The lock was cleared, but Orca’s server did not start.' + )} +

+ {check.notRestarted ?

{check.notRestarted}

: null} +
+ ) + } + if (check.status === 'none') { + return ( +

+ {translate( + 'auto.components.settings.recoverServer.none', + 'There is no lock to clear any more. Reconnect to start this host’s Orca server.' + )} +

+ ) + } + return ( +
+

{check.message}

+ {check.status === 'refused' || check.status === 'no-answer' ? ( + + ) : null} + {/* Its orcad's own reclaim is safer than a delete; only a host without one gets rm. */} + {check.status === 'unsupported' ? ( + check.reclaimWithoutPin ? ( + + ) : ( + + ) + ) : null} +
+ ) +} + +function ManualReclaim({ detail }: { detail: string }): React.JSX.Element { + const command = staleLockReclaimCommandFrom(detail) + if (!command) { + return + } + return ( +
+

+ {translate( + 'auto.components.settings.recoverServer.manualCommand', + 'Run this on the host, or delete the lock file shown in Details:' + )} +

+ +
+ ) +} + +function ManualLockDelete({ lockPath }: { lockPath: string | undefined }): React.JSX.Element { + if (!lockPath) { + return ( +

+ {translate( + 'auto.components.settings.recoverServer.manualDelete', + 'Delete the lock file shown in Details once you are sure no other machine uses it.' + )} +

+ ) + } + return ( +
+

+ {translate( + 'auto.components.settings.recoverServer.manualDeleteCommand', + 'Delete the lock on the host by hand:' + )} +

+ +

+ {translate( + 'auto.components.settings.recoverServer.manualDeleteWarning', + 'Only do this if no other machine sharing this home runs Orca for this profile.' + )} +

+
+ ) +} + +function LiteralCommand({ command }: { command: string }): React.JSX.Element { + return ( +
+      {command}
+    
+ ) +} + +function messageOf(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause) +} diff --git a/src/renderer/src/components/settings/SshTargetCard.tsx b/src/renderer/src/components/settings/SshTargetCard.tsx index 0a7349e37e6..8b8122bd5eb 100644 --- a/src/renderer/src/components/settings/SshTargetCard.tsx +++ b/src/renderer/src/components/settings/SshTargetCard.tsx @@ -240,7 +240,7 @@ export function SshTargetCard({ {state?.error && state.managedServer?.kind !== 'relay' ? (

{state.error}

) : null} - +
diff --git a/src/renderer/src/components/settings/SshTargetServerStatus.tsx b/src/renderer/src/components/settings/SshTargetServerStatus.tsx index e35622b2ca4..8de1b138710 100644 --- a/src/renderer/src/components/settings/SshTargetServerStatus.tsx +++ b/src/renderer/src/components/settings/SshTargetServerStatus.tsx @@ -13,6 +13,8 @@ import { SshHostNodeMirrorAction } from './SshHostNodeMirrorAction' import { OpenPlainSshTerminalButton } from '../plain-ssh-terminal/OpenPlainSshTerminalButton' import { canMoveSshHostAnyway, SshHostMoveAnywayDialog } from './SshHostMoveAnywayAction' import { canRestartSshHostServer, SshHostRestartServerDialog } from './SshHostRestartServerAction' +import { canRecoverSshHostServer, SshHostRecoverServerDialog } from './SshHostRecoverServerAction' +import { Button } from '../ui/button' import { getManagedOrcadApi } from './use-managed-server-catalog' export type SshTargetServerDialog = @@ -20,15 +22,18 @@ export type SshTargetServerDialog = | 'delta-move' | 'keep-server' | 'restart' + | 'recover' | 'move-anyway' /** Whether Orca on this SSH host serves it, and why not when it doesn't. */ export function SshTargetServerStatusLine({ target, - state + state, + onOpenDialog }: { target: SshTarget state: SshConnectionState | undefined + onOpenDialog?: (dialog: SshTargetServerDialog) => void }): React.JSX.Element | null { const line = sshHostServerStatusLine(target, state) if (!line) { @@ -54,6 +59,11 @@ export function SshTargetServerStatusLine({ targetLabel={target.label} status={state?.managedServer} /> + {onOpenDialog && canRecoverSshHostServer(state?.managedServer) ? ( + + ) : null}
) } @@ -72,8 +82,9 @@ export function SshTargetServerMenuItems({ const hasDetail = Boolean(sshHostServerStatusLine(target, state)?.detail) const sourceChanged = hasSshHostSourceChanged(target) const canRestart = canRestartSshHostServer(status) + const canRecover = canRecoverSshHostServer(status) const canMoveAnyway = canMoveSshHostAnyway(status) - if (!hasDetail && !sourceChanged && !canRestart && !canMoveAnyway) { + if (!hasDetail && !sourceChanged && !canRestart && !canRecover && !canMoveAnyway) { return null } return ( @@ -94,6 +105,11 @@ export function SshTargetServerMenuItems({ {translate('auto.components.settings.restartServer.action', 'Restart Orca’s server…')} ) : null} + {canRecover ? ( + onOpenDialog('recover')}> + {translate('auto.components.settings.recoverServer.action', 'Recover server…')} + + ) : null} {canMoveAnyway ? ( onOpenDialog('move-anyway')}> {translate('auto.components.settings.moveAnyway.action', 'Move anyway…')} @@ -151,6 +167,12 @@ export function SshTargetServerDialogs({ onOpenChange={openChange('restart')} onChanged={onChanged} /> + = { reclaimed: 0,