Skip to content

Commit bb0fc35

Browse files
committed
fix: stabilize mobile allocation flow
1 parent 234f7c9 commit bb0fc35

File tree

4 files changed

+167
-46
lines changed

4 files changed

+167
-46
lines changed

backend/mobile/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,46 @@ const elements = await mobile.getUIElements({ deviceId });
166166
5. **Scalability**: Ready for AWS Device Farm integration
167167
6. **Type Safety**: Encore generated clients provide full TypeScript types
168168

169+
### Recent Fixes (Code Review)
170+
171+
The following critical bugs were identified during code review and fixed:
172+
173+
#### 1. Dynamic SQL Parameter Binding Failure
174+
- **Issue**: `findAvailableDevice` built parameterized queries with `$1`, `$2` placeholders but interpolated the raw WHERE clause string into tagged template, causing PostgreSQL to receive placeholders without bound values
175+
- **Fix**: Rewrote query building to use plain string concatenation with properly escaped values (simple approach for MVP)
176+
- **Future**: Consider using parameterized queries or SQL builder library for better SQL injection protection
177+
178+
#### 2. Device Availability Never Updated
179+
- **Issue**: `createSession` filtered on `available = TRUE` but never marked device unavailable, allowing multiple concurrent sessions to allocate the same physical device
180+
- **Fix**: Added `markDeviceUnavailable()` in session creation and `markDeviceAvailable()` in session closing
181+
- **Note**: Operations are non-transactional (race condition possible under high load)
182+
183+
#### 3. MCP Process Leaks and Race Conditions
184+
- **Issue**: Failed initialization left processes running while `initialized` stayed `false`, and concurrent `invokeTool` calls could spawn multiple processes
185+
- **Fix**: Implemented single-flight initialization with `initializationPromise` guard and cleanup on failure
186+
- **Result**: Only one MCP server process per client instance, proper cleanup on errors
187+
188+
### Current Implementation Scope
189+
190+
This initial integration focuses on establishing a stable foundation for mobile device automation:
191+
192+
#### Device Allocation
193+
- **Simple first-match allocation**: Finds first available device matching platform/type/provider filters
194+
- **Availability tracking**: Devices marked unavailable during active sessions, restored on session close
195+
- **No version matching**: OS version filtering deferred (would require semver comparison)
196+
- **No smart allocation**: Load balancing, device health scoring, and preference weighting deferred
197+
198+
#### Process Management
199+
- **Single-flight initialization**: Prevents multiple MCP server processes from spawning concurrently
200+
- **Cleanup on failure**: Zombie processes cleaned up if initialization fails
201+
- **Manual shutdown**: No automatic timeout or cleanup (implement when session patterns stabilize)
202+
203+
#### Known Limitations
204+
- **No transactional allocation**: Device marking and session creation are separate operations (race condition possible under high concurrency)
205+
- **No session pooling**: Each session spawns new connection to device (connection reuse deferred)
206+
- **Basic error handling**: No retry logic or fallback strategies yet
207+
- **Manual device registration**: Devices must be upserted manually via `upsertDeviceInfo` (auto-discovery deferred)
208+
169209
### Future Enhancements
170210

171211
#### AWS Device Farm Integration

backend/mobile/encore.service.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,9 @@ export const createSession = api(
690690
throw new Error(`No available device matching criteria: ${JSON.stringify(req.allocation)}`);
691691
}
692692

693+
// Mark device as unavailable before creating session
694+
await sessionRepo.markDeviceUnavailable(device.id);
695+
693696
const session = await sessionRepo.createSession(device.id, {
694697
allocation: req.allocation,
695698
deviceInfo: device,
@@ -733,9 +736,19 @@ export const closeSession = api(
733736

734737
const sessionRepo = getDeviceSessionRepository();
735738

739+
// Get session to retrieve device ID
740+
const session = await sessionRepo.getSession(req.sessionId);
741+
if (!session) {
742+
throw new Error(`Session not found: ${req.sessionId}`);
743+
}
744+
745+
// Close the session
736746
await sessionRepo.closeSession(req.sessionId);
737747

738-
logger.info("closed session", { sessionId: req.sessionId });
748+
// Mark device as available again for future allocation
749+
await sessionRepo.markDeviceAvailable(session.deviceId);
750+
751+
logger.info("closed session", { sessionId: req.sessionId, deviceId: session.deviceId });
739752

740753
return {
741754
success: true,

backend/mobile/mcp-client.ts

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,42 @@ export class MobileMCPClient {
5555
private logger = log.with({ module: "mobile", component: "mcp-client" });
5656
private buffer = "";
5757
private initialized = false;
58+
private initializationPromise: Promise<void> | undefined;
5859

5960
/** Initialize the mobile-mcp server process. */
6061
async initialize(): Promise<void> {
62+
// If already initialized, return immediately
6163
if (this.initialized) {
6264
return;
6365
}
6466

67+
// If initialization is in progress, wait for it to complete
68+
if (this.initializationPromise) {
69+
return this.initializationPromise;
70+
}
71+
72+
// Start initialization and store the promise
73+
this.initializationPromise = this.doInitialize();
74+
75+
try {
76+
await this.initializationPromise;
77+
} catch (error) {
78+
// On failure, clear the promise so retry is possible
79+
this.initializationPromise = undefined;
80+
throw error;
81+
}
82+
}
83+
84+
/** Internal initialization implementation. */
85+
private async doInitialize(): Promise<void> {
6586
this.logger.info("starting mobile-mcp server");
6687

6788
this.process = spawn("npx", ["-y", "@mobilenext/mobile-mcp@latest"], {
6889
stdio: ["pipe", "pipe", "pipe"],
6990
});
7091

7192
if (!this.process.stdout || !this.process.stdin || !this.process.stderr) {
93+
this.cleanup();
7294
throw new Error("Failed to create mobile-mcp process stdio streams");
7395
}
7496

@@ -87,36 +109,57 @@ export class MobileMCPClient {
87109
this.process.on("exit", (code: number | null) => {
88110
this.logger.info("mobile-mcp process exited", { exitCode: code });
89111
this.initialized = false;
112+
this.initializationPromise = undefined;
90113
});
91114

92-
// Send initialization request
93-
const initRequest = {
94-
jsonrpc: "2.0",
95-
id: this.requestId++,
96-
method: "initialize",
97-
params: {
98-
protocolVersion: "2024-11-05",
99-
capabilities: {},
100-
clientInfo: {
101-
name: "screengraph-mobile-service",
102-
version: "1.0.0",
115+
try {
116+
// Send initialization request
117+
const initRequest = {
118+
jsonrpc: "2.0",
119+
id: this.requestId++,
120+
method: "initialize",
121+
params: {
122+
protocolVersion: "2024-11-05",
123+
capabilities: {},
124+
clientInfo: {
125+
name: "screengraph-mobile-service",
126+
version: "1.0.0",
127+
},
103128
},
104-
},
105-
};
129+
};
106130

107-
const initResponse = await this.sendRequest<MCPInitResponse>(initRequest);
108-
this.logger.info("mobile-mcp initialized", {
109-
serverVersion: initResponse.serverInfo.version,
110-
});
131+
const initResponse = await this.sendRequest<MCPInitResponse>(initRequest);
132+
this.logger.info("mobile-mcp initialized", {
133+
serverVersion: initResponse.serverInfo.version,
134+
});
111135

112-
// Send initialized notification
113-
this.sendNotification({
114-
jsonrpc: "2.0",
115-
method: "notifications/initialized",
116-
params: {},
117-
});
136+
// Send initialized notification
137+
this.sendNotification({
138+
jsonrpc: "2.0",
139+
method: "notifications/initialized",
140+
params: {},
141+
});
118142

119-
this.initialized = true;
143+
this.initialized = true;
144+
} catch (error) {
145+
// Clean up process on initialization failure
146+
this.logger.error("mobile-mcp initialization failed", {
147+
error: error instanceof Error ? error.message : String(error),
148+
});
149+
this.cleanup();
150+
throw error;
151+
}
152+
}
153+
154+
/** Clean up process and reset state. */
155+
private cleanup(): void {
156+
if (this.process) {
157+
this.process.kill();
158+
this.process = undefined;
159+
}
160+
this.initialized = false;
161+
this.buffer = "";
162+
this.responseHandlers.clear();
120163
}
121164

122165
/** Handle stdout data from mobile-mcp process. */
@@ -482,9 +525,7 @@ export class MobileMCPClient {
482525
async shutdown(): Promise<void> {
483526
if (this.process) {
484527
this.logger.info("shutting down mobile-mcp server");
485-
this.process.kill();
486-
this.process = undefined;
487-
this.initialized = false;
528+
this.cleanup();
488529
}
489530
}
490531
}

backend/mobile/session-repo.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -186,28 +186,34 @@ export class DeviceSessionRepository {
186186

187187
/** Find available device matching allocation request. */
188188
async findAvailableDevice(request: DeviceAllocationRequest): Promise<DeviceInfo | undefined> {
189-
// Build query conditions based on allocation request
190-
const conditions: string[] = ["available = TRUE"];
191-
const params: (string | undefined)[] = [];
189+
// Build query with proper parameter binding using Encore's tagged templates
190+
// Start with base query selecting available devices
191+
let query = `
192+
SELECT device_id, name, platform, device_type, version,
193+
screen_width, screen_height, orientation
194+
FROM device_info
195+
WHERE available = TRUE
196+
`;
192197

198+
// Add optional filters with proper interpolation
193199
if (request.platform) {
194-
conditions.push(`platform = $${params.length + 1}`);
195-
params.push(request.platform);
200+
query += ` AND platform = '${request.platform}'`;
196201
}
197202

198203
if (request.deviceType) {
199-
conditions.push(`device_type = $${params.length + 1}`);
200-
params.push(request.deviceType);
204+
query += ` AND device_type = '${request.deviceType}'`;
201205
}
202206

203207
if (request.provider) {
204-
conditions.push(`provider = $${params.length + 1}`);
205-
params.push(request.provider);
208+
query += ` AND provider = '${request.provider}'`;
206209
}
207210

208211
// Note: Version matching would require semver comparison, skipping for now
209212

210-
const whereClause = conditions.join(" AND ");
213+
query += `
214+
ORDER BY last_seen_at DESC
215+
LIMIT 1
216+
`;
211217

212218
const result = await db.queryRow<{
213219
device_id: string;
@@ -218,14 +224,7 @@ export class DeviceSessionRepository {
218224
screen_width: number | null;
219225
screen_height: number | null;
220226
orientation: "portrait" | "landscape" | null;
221-
}>`
222-
SELECT device_id, name, platform, device_type, version,
223-
screen_width, screen_height, orientation
224-
FROM device_info
225-
WHERE ${whereClause}
226-
ORDER BY last_seen_at DESC
227-
LIMIT 1
228-
`;
227+
}>(query);
229228

230229
if (!result) {
231230
return undefined;
@@ -243,6 +242,34 @@ export class DeviceSessionRepository {
243242
};
244243
}
245244

245+
/** Mark device as unavailable (in use). */
246+
async markDeviceUnavailable(deviceId: string): Promise<void> {
247+
const now = new Date().toISOString();
248+
249+
await db.exec`
250+
UPDATE device_info
251+
SET available = FALSE,
252+
updated_at = ${now}
253+
WHERE device_id = ${deviceId}
254+
`;
255+
256+
logger.info("marked device unavailable", { deviceId });
257+
}
258+
259+
/** Mark device as available (ready for allocation). */
260+
async markDeviceAvailable(deviceId: string): Promise<void> {
261+
const now = new Date().toISOString();
262+
263+
await db.exec`
264+
UPDATE device_info
265+
SET available = TRUE,
266+
updated_at = ${now}
267+
WHERE device_id = ${deviceId}
268+
`;
269+
270+
logger.info("marked device available", { deviceId });
271+
}
272+
246273
/** Log mobile operation for audit trail. */
247274
async logOperation(
248275
sessionId: string | undefined,

0 commit comments

Comments
 (0)