Skip to content

Commit 5802375

Browse files
committed
Fix missing bridge env reconnect diagnostics
1 parent 7dc2748 commit 5802375

10 files changed

Lines changed: 251 additions & 26 deletions

File tree

README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,13 @@ If you want a download-to-working walkthrough, use the
140140
name.
141141
- **Steam bridge games**: prefer `SteamManaged` launch. It resolves the Steam
142142
app manifest to the installed game executable, starts Steam if needed, and
143-
launches the real game process with GABP environment variables. Use
144-
`gabs games doctor <id>` to inspect a config and `gabs games repair <id>` to
145-
convert an older `SteamAppId` launcher URL config.
143+
launches the resolved executable with GABP environment variables. Some Steam
144+
or platform-managed apps can still relaunch the final game process without
145+
those variables; `games_status` reports that as
146+
`process-bridge-environment-missing`. Use `gabs games doctor <id>` to inspect
147+
a config, `gabs games repair <id>` to convert an older `SteamAppId` launcher
148+
URL config, and `DirectPath` or `CustomCommand` when the final process does
149+
not inherit the bridge environment.
146150
- **More than one AI session**: that is fine. GABS coordinates ownership per
147151
game with a short active-owner lease. You can hop between live sessions:
148152
`games_connect` takes over naturally after the previous session goes idle,
@@ -158,9 +162,10 @@ If you want a download-to-working walkthrough, use the
158162
`games_start` with `resetEndpoint: true` only after confirming the cache
159163
should be rotated for a new process.
160164
- **Confusing bridge state**: start with `games_status`. It compares the
161-
runtime state and process environment where the OS allows it. If a Steam
162-
launcher URL config reused old GABP environment, run
163-
`gabs games repair <id>` to switch to managed Steam launch.
165+
runtime state and process environment where the OS allows it. If
166+
`diagnostics.code` is `process-bridge-environment-missing`, the running game
167+
process is visible but not attachable through the expected GABP environment;
168+
adjust the launch mode instead of retrying `games_connect`.
164169

165170
## How It Works
166171

docs/AI_CLIENT_SETUP.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,12 @@ Examples of `stopProcessName`:
9292
- Java-based FactorySim setups: `java`
9393

9494
For Steam bridge games, prefer `SteamManaged`; it resolves the Steam app
95-
manifest to the installed executable and starts Steam if needed. For launcher
96-
URL modes such as `SteamAppId` and `EpicAppId`, `stopProcessName` is required.
97-
Without it, GABS can launch the game but cannot stop the real game process
98-
reliably.
95+
manifest to the installed executable and starts Steam if needed. Still verify
96+
`games_status`: if it reports `process-bridge-environment-missing`, the final
97+
game process did not inherit the bridge environment and the config should use
98+
`DirectPath` or `CustomCommand`. For launcher URL modes such as `SteamAppId`
99+
and `EpicAppId`, `stopProcessName` is required. Without it, GABS can launch the
100+
game but cannot stop the real game process reliably.
99101

100102
## What Success Looks Like
101103

docs/CONFIGURATION.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,12 @@ Best for Steam games with GABP bridges.
177177
```
178178
You can find the App ID in the game's Steam store URL. GABS locates the Steam
179179
library, reads the app manifest, starts the Steam client if needed, launches the
180-
real game executable with GABP environment variables, and prepares
180+
resolved executable with GABP environment variables, and prepares
181181
`steam_appid.txt` when direct Steamworks startup requires it. Configured `args`
182-
are passed to the game in this mode.
182+
are passed to the game in this mode. If Steam or the platform relaunches the
183+
final game process without inheriting those variables, `games_status` reports
184+
`process-bridge-environment-missing`; use `DirectPath` or `CustomCommand` for a
185+
wrapper that the final process actually inherits from.
183186

184187
Use `gabs games doctor <id>` to inspect the resolved executable and
185188
`gabs games repair <id>` to switch an older `SteamAppId` config to this mode.
@@ -205,8 +208,9 @@ bridge environment directly.
205208
In launcher-driven setups, an already-running platform launcher process can
206209
prevent GABS from proving that new environment variables reached the real game
207210
process. `games_status` reports whether the real game process environment is
208-
readable. For deterministic env and argument control, use `SteamManaged`,
209-
`DirectPath`, or `CustomCommand`. For an existing Steam launcher config, run
211+
readable. Prefer `SteamManaged` over launcher URL mode, but still verify the
212+
final process environment. If it reports `process-bridge-environment-missing`,
213+
use `DirectPath` or `CustomCommand`. For an existing Steam launcher config, run
210214
`gabs games repair <id>`.
211215

212216
### EpicAppId
@@ -470,8 +474,10 @@ mandatory. `SteamManaged` launches the resolved game executable directly, so
470474
4. Run `games_status` and inspect `diagnostics.code`, `diagnostics.message`,
471475
and `nextActions`; it can identify stale runtime state, runtime ownership,
472476
and whether the real game process environment is readable.
473-
5. For Steam launcher URL configs, run `gabs games repair <id>` to switch to
474-
managed Steam launch.
477+
5. If `diagnostics.code` is `process-bridge-environment-missing`, the running
478+
process is visible but cannot be attached through its environment. For Steam
479+
launcher URL configs, run `gabs games repair <id>` first; if managed launch
480+
still loses the environment, use `DirectPath` or `CustomCommand`.
475481

476482
### "Configuration not found"
477483
The config file is created automatically when you add your first game. If it's missing, run `gabs games add` to create a new one.

docs/DEPLOYMENT.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,5 +309,8 @@ not make the game-side bridge read `bridge.json`; use `games_connect` so GABS
309309
can prefer the running process environment. If `games_start` reports
310310
`endpoint_cache_in_use`, attach with `games_connect` or use `resetEndpoint:
311311
true` only after confirming the cached endpoint should be rotated. For Steam
312-
games that need deterministic environment injection, use `SteamManaged` or run
313-
`gabs games repair <id>` to convert an older `SteamAppId` config.
312+
games, prefer `SteamManaged` over launcher URL mode or run `gabs games repair
313+
<id>` to convert an older `SteamAppId` config. If `games_status` reports
314+
`process-bridge-environment-missing`, the final process did not inherit the
315+
bridge environment; use `DirectPath` or `CustomCommand` instead of retrying
316+
`games_connect`.

docs/SOLUTION_SUMMARY.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
> support explicit active takeover with `games.connect {"forceTakeover": true}`.
88
> Current Steam bridge games should use `SteamManaged`, which resolves the
99
> installed Steam app executable and launches it directly with GABP environment.
10-
> `SteamAppId` remains as a legacy launcher URL mode.
10+
> `SteamAppId` remains as a legacy launcher URL mode. If a platform-managed
11+
> launch still drops the bridge environment before the final process starts,
12+
> `games_status` reports `process-bridge-environment-missing`; use `DirectPath`
13+
> or `CustomCommand` for that setup.
1114
1215
## Problem Analysis
1316

@@ -150,7 +153,7 @@ type ProcessError struct {
150153
-**Serialized starting**: Only one process starting at a time
151154
-**Error handling**: Structured errors with context
152155
-**Steam/Epic games**: Proper legacy launcher vs directly managed process distinction
153-
-**SteamManaged games**: Steam apps can be launched as resolved executables with deterministic GABP environment injection
156+
-**SteamManaged games**: Steam apps can be launched as resolved executables with bridge environment injection, with status diagnostics when the final process does not inherit it
154157

155158
**Status Reporting Examples**:
156159
- **With tracking**: "running (GABS is tracking the game process)"

internal/mcp/reconnect_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,77 @@ func TestGamesConnectPrefersReadableProcessEnvironmentOverBridgeFile(t *testing.
225225
}
226226
}
227227

228+
func TestGamesConnectDoesNotUseBridgeFileWhenReadableProcessEnvironmentLacksEndpoint(t *testing.T) {
229+
if runtime.GOOS == "windows" {
230+
t.Skip("process environment inspection is not supported on Windows")
231+
}
232+
233+
tmpDir := t.TempDir()
234+
writeBridgeJSONForTest(t, tmpDir, "adventure", unusedLocalPort(t), "bridge-file-token")
235+
236+
exe, err := os.Executable()
237+
if err != nil {
238+
t.Fatalf("failed to locate helper executable: %v", err)
239+
}
240+
cmd := exec.Command(exe, "-test.run=TestSharedRuntimeStateHelperProcess")
241+
cmd.Env = append(os.Environ(), "GABS_HELPER_PROCESS=1")
242+
if err := cmd.Start(); err != nil {
243+
t.Fatalf("failed to start helper process: %v", err)
244+
}
245+
defer func() {
246+
_ = cmd.Process.Kill()
247+
_, _ = cmd.Process.Wait()
248+
}()
249+
250+
runtimeState := process.RuntimeState{
251+
GameID: "adventure",
252+
Status: process.RuntimeStateStatusRunning,
253+
OwnerPID: os.Getpid(),
254+
GamePID: cmd.Process.Pid,
255+
}
256+
if err := process.SaveRuntimeState("adventure", tmpDir, runtimeState); err != nil {
257+
t.Fatalf("failed to write runtime state: %v", err)
258+
}
259+
260+
gamesConfig := &config.GamesConfig{
261+
Games: map[string]config.GameConfig{
262+
"adventure": {
263+
ID: "adventure",
264+
Name: "AdventureGame",
265+
LaunchMode: "SteamManaged",
266+
Target: "123456",
267+
},
268+
},
269+
}
270+
271+
server := NewServerForTesting(util.NewLogger("error"))
272+
server.SetConfigDir(tmpDir)
273+
server.RegisterGameManagementTools(gamesConfig, 100*time.Millisecond, time.Second)
274+
275+
connectText := marshalMessage(t, server.HandleMessage(&Message{
276+
JSONRPC: "2.0",
277+
Method: "tools/call",
278+
ID: json.RawMessage(`"connect-missing-process-env"`),
279+
Params: map[string]interface{}{
280+
"name": "games.connect",
281+
"arguments": map[string]interface{}{
282+
"gameId": "adventure",
283+
"timeout": 1,
284+
},
285+
},
286+
}))
287+
288+
if !strings.Contains(connectText, `"isError":true`) {
289+
t.Fatalf("expected connect to fail, got: %s", connectText)
290+
}
291+
if !strings.Contains(connectText, "running process environment is readable") || !strings.Contains(connectText, "does not contain GABP_SERVER_PORT/GABP_TOKEN") {
292+
t.Fatalf("expected readable missing-env diagnosis, got: %s", connectText)
293+
}
294+
if strings.Contains(connectText, "Failed to connect to GABP server") || strings.Contains(connectText, "after 1s") {
295+
t.Fatalf("connect should not dial the internal endpoint when process env is readable but missing, got: %s", connectText)
296+
}
297+
}
298+
228299
func TestGamesCallToolFailsFastAndStatusTurnsDisconnectedAfterBridgeDrop(t *testing.T) {
229300
tmpDir, err := os.MkdirTemp("", "gabs-reconnect-disconnect")
230301
if err != nil {

internal/mcp/state_diagnostics.go

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,17 @@ func (s *Server) gameStateDiagnostics(game config.GameConfig, status string) map
6464
message = "Stale runtime state was removed."
6565
}
6666

67-
if (game.LaunchMode == "SteamAppId" || game.LaunchMode == "EpicAppId") && status != "stopped" && status != "stale-runtime-cleaned" && !processEnv.Present {
67+
if runningStatusNeedsBridgeEnvironment(status) && readableProcessEnvLacksAttachableBridgeEndpoint(game, processEnv) {
68+
code = "process-bridge-environment-missing"
69+
severity = "warning"
70+
message = processBridgeEnvironmentMissingMessage(game, processEnv)
71+
}
72+
73+
if runningStatusNeedsBridgeEnvironment(status) && platformManagedLaunchModeNeedsVisibleBridgeEnvironment(game) && !processEnvBridgeEndpointUsableForGame(game, processEnv) {
6874
if game.LaunchMode == "SteamAppId" {
6975
warnings = append(warnings, "Could not verify GABP environment on the real game process; SteamAppId launcher URL mode can reuse stale environment from an already-running launcher. Run 'gabs games repair "+game.ID+"' to switch to managed Steam launch.")
76+
} else if game.LaunchMode == "SteamManaged" {
77+
warnings = append(warnings, "Could not verify GABP environment on the real game process; Steam-managed launch can still lose environment variables if the platform relaunches the app. Use DirectPath or CustomCommand when the final game process does not inherit the bridge environment.")
7078
} else {
7179
warnings = append(warnings, fmt.Sprintf("Could not verify GABP environment on the real game process; %s launchers can reuse stale environment from an already-running launcher.", game.LaunchMode))
7280
}
@@ -97,6 +105,12 @@ func nextActionsForGameStateDiagnostics(game config.GameConfig, diagnostics map[
97105
if diagnostics == nil {
98106
return fallback
99107
}
108+
code, _ := diagnostics["code"].(string)
109+
if code == "process-bridge-environment-missing" {
110+
return []map[string]interface{}{
111+
mcpNextAction("games_show", map[string]interface{}{"gameId": game.ID}, "Review the launch configuration; the running process is visible but does not expose an attachable GABP environment."),
112+
}
113+
}
100114
if game.LaunchMode == "SteamAppId" {
101115
status, _ := diagnostics["processEnvironment"].(map[string]interface{})
102116
if present, _ := status["present"].(bool); !present {
@@ -109,6 +123,51 @@ func nextActionsForGameStateDiagnostics(game config.GameConfig, diagnostics map[
109123
return fallback
110124
}
111125

126+
func runningStatusNeedsBridgeEnvironment(status string) bool {
127+
switch status {
128+
case "running", "shared-running", "running-disconnected", "disconnected":
129+
return true
130+
default:
131+
return false
132+
}
133+
}
134+
135+
func processEnvBridgeEndpointUsableForGame(game config.GameConfig, processEnv processEnvDiagnostic) bool {
136+
if !processEnv.Present || processEnv.Port <= 0 || strings.TrimSpace(processEnv.Token) == "" {
137+
return false
138+
}
139+
return processEnv.GameID == "" || processEnv.GameID == game.ID
140+
}
141+
142+
func readableProcessEnvLacksAttachableBridgeEndpoint(game config.GameConfig, processEnv processEnvDiagnostic) bool {
143+
if !platformManagedLaunchModeNeedsVisibleBridgeEnvironment(game) {
144+
return false
145+
}
146+
if !processEnv.Readable || processEnv.PID <= 0 {
147+
return false
148+
}
149+
return !processEnvBridgeEndpointUsableForGame(game, processEnv)
150+
}
151+
152+
func platformManagedLaunchModeNeedsVisibleBridgeEnvironment(game config.GameConfig) bool {
153+
switch game.LaunchMode {
154+
case "SteamManaged", "SteamAppId", "EpicAppId":
155+
return true
156+
default:
157+
return false
158+
}
159+
}
160+
161+
func processBridgeEnvironmentMissingMessage(game config.GameConfig, processEnv processEnvDiagnostic) string {
162+
if processEnv.GameID != "" && processEnv.GameID != game.ID {
163+
return fmt.Sprintf("The running process environment is readable, but GABS_GAME_ID is %q instead of %q. GABS will not attach using cached endpoint state.", processEnv.GameID, game.ID)
164+
}
165+
if processEnv.Present {
166+
return "The running process environment is readable, but it does not contain a complete GABP_SERVER_PORT/GABP_TOKEN pair. GABS will not attach using cached endpoint state."
167+
}
168+
return "The running process environment is readable, but it does not contain GABP_SERVER_PORT/GABP_TOKEN. The game was not launched with the GABS bridge environment, or a launcher dropped it before the final process started."
169+
}
170+
112171
func gameStateDiagnosticMessage(statusItem map[string]interface{}) string {
113172
diagnostics, _ := statusItem["diagnostics"].(map[string]interface{})
114173
if diagnostics == nil {

internal/mcp/status_regression_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,71 @@ func TestGamesStatusIgnoresBridgeFileMismatchWhenProcessEnvironmentIsReadable(t
332332
}
333333
}
334334

335+
func TestGamesStatusWarnsWhenReadableProcessEnvironmentLacksEndpoint(t *testing.T) {
336+
if runtime.GOOS == "windows" {
337+
t.Skip("process environment inspection is not supported on Windows")
338+
}
339+
340+
tmpDir, err := os.MkdirTemp("", "gabs-status-missing-env")
341+
if err != nil {
342+
t.Fatalf("failed to create temp dir: %v", err)
343+
}
344+
defer os.RemoveAll(tmpDir)
345+
346+
game := config.GameConfig{
347+
ID: "steam-managed-env-game",
348+
Name: "Steam Managed Env Game",
349+
LaunchMode: "SteamManaged",
350+
Target: "123456",
351+
}
352+
gamesConfig := &config.GamesConfig{
353+
Games: map[string]config.GameConfig{game.ID: game},
354+
}
355+
356+
writeBridgeFileForStatusTest(t, tmpDir, game.ID, unusedLocalPort(t), "bridge-token")
357+
exe, err := os.Executable()
358+
if err != nil {
359+
t.Fatalf("failed to locate test executable: %v", err)
360+
}
361+
cmd := exec.Command(exe, "-test.run=TestSharedRuntimeStateHelperProcess")
362+
cmd.Env = append(os.Environ(), "GABS_HELPER_PROCESS=1")
363+
if err := cmd.Start(); err != nil {
364+
t.Fatalf("failed to start helper process: %v", err)
365+
}
366+
defer func() {
367+
_ = cmd.Process.Kill()
368+
_, _ = cmd.Process.Wait()
369+
}()
370+
371+
runtimeState := process.RuntimeState{
372+
GameID: game.ID,
373+
Status: process.RuntimeStateStatusRunning,
374+
OwnerPID: os.Getpid(),
375+
GamePID: cmd.Process.Pid,
376+
}
377+
if err := process.SaveRuntimeState(game.ID, tmpDir, runtimeState); err != nil {
378+
t.Fatalf("failed to seed runtime state: %v", err)
379+
}
380+
381+
server := NewServerForTesting(util.NewLogger("error"))
382+
server.SetConfigDir(tmpDir)
383+
server.RegisterGameManagementTools(gamesConfig, 100*time.Millisecond, time.Second)
384+
385+
statusText := marshalMessage(t, server.HandleMessage(toolCallMessage("status-missing-env", "games.status", game.ID)))
386+
if !strings.Contains(statusText, `"code":"process-bridge-environment-missing"`) {
387+
t.Fatalf("expected missing process bridge environment diagnosis, got: %s", statusText)
388+
}
389+
if !strings.Contains(statusText, "Steam-managed launch can still lose environment variables") {
390+
t.Fatalf("expected SteamManaged warning, got: %s", statusText)
391+
}
392+
if strings.Contains(statusText, `"tool":"games_connect"`) {
393+
t.Fatalf("status should not recommend games_connect when the running process env is readable but missing, got: %s", statusText)
394+
}
395+
if strings.Contains(statusText, "bridge-token") {
396+
t.Fatalf("raw bridge token leaked in status response: %s", statusText)
397+
}
398+
}
399+
335400
func writeBridgeFileForStatusTest(t *testing.T, configDir, gameID string, port int, token string) string {
336401
t.Helper()
337402

internal/mcp/stdio_server.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3291,6 +3291,10 @@ func (s *Server) cleanupRuntimeStateInternal(gameId string) {
32913291

32923292
func (s *Server) adoptProcessBridgeEndpoint(game config.GameConfig, runtimeState *process.RuntimeState, current bridgeEndpoint) (bridgeEndpoint, bool) {
32933293
processEnv := s.inspectGameBridgeEnvironment(game, runtimeState)
3294+
return s.adoptProcessBridgeEndpointFromDiagnostic(game, processEnv, current)
3295+
}
3296+
3297+
func (s *Server) adoptProcessBridgeEndpointFromDiagnostic(game config.GameConfig, processEnv processEnvDiagnostic, current bridgeEndpoint) (bridgeEndpoint, bool) {
32943298
if !processEnv.Present || processEnv.Port <= 0 || strings.TrimSpace(processEnv.Token) == "" {
32953299
return current, false
32963300
}
@@ -3323,11 +3327,16 @@ func (s *Server) adoptProcessBridgeEndpoint(game config.GameConfig, runtimeState
33233327
}
33243328

33253329
func (s *Server) resolveConnectBridgeEndpoint(game config.GameConfig, runtimeState *process.RuntimeState) (bridgeEndpoint, error) {
3326-
endpoint, _ := s.adoptProcessBridgeEndpoint(game, runtimeState, bridgeEndpoint{})
3330+
processEnv := s.inspectGameBridgeEnvironment(game, runtimeState)
3331+
endpoint, _ := s.adoptProcessBridgeEndpointFromDiagnostic(game, processEnv, bridgeEndpoint{})
33273332
if endpoint.Port > 0 && strings.TrimSpace(endpoint.Token) != "" {
33283333
return endpoint, nil
33293334
}
33303335

3336+
if readableProcessEnvLacksAttachableBridgeEndpoint(game, processEnv) {
3337+
return bridgeEndpoint{}, fmt.Errorf("%s", processBridgeEnvironmentMissingMessage(game, processEnv))
3338+
}
3339+
33313340
_, port, token, err := config.ReadBridgeJSON(game.ID, s.configDir)
33323341
if err != nil {
33333342
return bridgeEndpoint{}, fmt.Errorf("no readable live process environment and internal bridge endpoint was unavailable: %w", err)

0 commit comments

Comments
 (0)