Skip to content

feat(sim,engine): @linkcode/sim SDK + SimulatorBackend with per-session device ownership (CODE-393)#249

Open
AprilNEA wants to merge 4 commits into
xuan/code-392from
xuan/code-393
Open

feat(sim,engine): @linkcode/sim SDK + SimulatorBackend with per-session device ownership (CODE-393)#249
AprilNEA wants to merge 4 commits into
xuan/code-392from
xuan/code-393

Conversation

@AprilNEA

Copy link
Copy Markdown
Member

Summary

Stacked on #247. The TypeScript layer above the linkcode-sim sidecar:

  • @linkcode/sim (new package, packages/host/sim): framed-stdio codec mirroring the Rust proto (zod-validated at the boundary), and SimSidecarClient — lazy spawn, per-request correlation with outer reply deadlines, crash fail-pending + respawn, EOF shutdown. Binary path is a constructor arg; no packaging probing in the package.
  • engine: SimulatorBackend port (structural, mirrors the PtyBackend seam — neither package imports the other) + SimulatorService, the policy layer: device↔session ownership (auto-claim, conflict on cross-session use), ≤4 devices per session (limit_exceeded), 10-min idle reclaim of service-booted devices after their session stops (resuming inside the window keeps the device), user-booted devices never auto-shutdown, engine-shutdown reclaim. Wire (CODE-394) and MCP tools (CODE-395) must route through this service.
  • daemon/desktop: resolveSimSidecarPath() (LINKCODE_SIM_SIDECAR_PATH override → dev target/release fallback; non-macOS resolves to none), injected as simulatorBackend only when configured; the packaged supervisor exports the env from resourcesPath (macOS only). docs/ENVIRONMENT.md updated.

Verification

  • pnpm check:ci + pnpm test green (unit: 5 client + 5 codec + 7 registry tests).
  • SDK-driven real-device loop (LINKCODE_SIM_E2E=1, opt-in vitest): cold boot → install (fixture UIKit app) → launch (pid) → screenshot (JPEG >10 KB) → terminate → shutdown, 13s on an iPhone 17 / iOS 26.5 simulator; all devices confirmed shut down afterwards.
  • Dev daemon booted under an isolated profile with the wiring live: health endpoint serves, no errors, graceful SIGTERM drain.

@linear-code

linear-code Bot commented Jul 22, 2026

Copy link
Copy Markdown

CODE-393

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d9670d770

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

this.onChildGone();
}
});
// A failed spawn (e.g. missing binary) errors the pipes; a broken pipe means the child is

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore exit events from replaced sidecars

After a malformed frame, the data handler calls child.kill() and immediately invokes onChildGone(), allowing the next request to spawn a replacement before this old child's asynchronous exit event arrives. This unguarded handler then clears the replacement from this.child, resets its shared decoder, and rejects all of its pending requests, so recovery can fail and leave the replacement process orphaned; associate teardown with the emitting child and ignore events when it is no longer current.

Useful? React with 👍 / 👎.

Comment on lines +59 to +60
await this.backend.boot(udid);
const claim = this.claims.get(udid);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve claims while a service boot is in flight

If the owning session stops while backend.boot() is pending, releaseSession() sees bootedByService === false and deletes the claim. When the boot later succeeds, this lookup finds nothing, leaving a device that the service booted unowned and absent from both idle reclaim and engine-shutdown reclaim; coordinate release with the in-flight boot so a successful boot remains tracked and is eventually shut down.

Useful? React with 👍 / 👎.

Comment on lines +162 to +164
this.drop(udid);
// Reclaim is best-effort: the device may already be gone (deleted in Xcode, host reboot).
this.backend.shutdownDevice(udid).catch(noop);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delay freeing a device until reclaim shutdown finishes

At the idle deadline, the claim is removed before shutdownDevice() settles. Because the sidecar executes requests concurrently, another session can claim the same UDID immediately and boot, launch, or capture it while the previous session's shutdown is still running, causing the new operation or device state to be disrupted; retain the claim through the shutdown and only free the matching claim after the promise settles.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds the TypeScript simulator SDK and connects simulator ownership to the engine and packaged daemon. The main changes are:

  • A framed-stdio client, codec, schemas, and tests in @linkcode/sim.
  • Per-session device claims, limits, idle reclaim, and shutdown cleanup in the engine.
  • macOS sidecar resolution and desktop environment injection.
  • Package, TypeScript, lockfile, and environment documentation updates.

Confidence Score: 4/5

Sidecar replacement and simulator ownership transitions can invalidate active requests or leave devices in the wrong lifecycle state.

  • Delayed events from a dead sidecar can tear down its replacement.
  • A failed stdin write can leave a request waiting for a long outer deadline.
  • Session release during boot can prevent service-owned devices from being reclaimed.
  • Idle reclaim exposes a device before its shutdown completes.

packages/host/sim/src/client.ts and packages/host/engine/src/simulator/service.ts

T-Rex T-Rex Logs

What T-Rex did

    • Verified the stale child events replacement scenario by running a focused Vitest harness that crashes child A, spawns child B for request r2, and emits a delayed error from child A, confirming that the stale child-A error rejects child B's pending request and that Vitest exits with code 1 on the assertion about B's resolution.
    • Verified the failed writes wait-for-deadline condition by executing a controlled-destroyed-stdin Vitest harness; writeFrame returned false with ERR_STREAM_DESTROYED, the boot promise remained pending, and after 25 ms the pending map still contained the request with no rejection.
    • Demonstrated the boot completion ownership loss scenario by running a deferred-backend lifecycle test that released the owning session while boot was pending; the device ended up without an owner and idle-reclaim or engine shutdown produced zero shutdownDevice calls, causing the reclamation assertion to fail.
    • Demonstrated the reclaim frees device too early scenario by testing a SimulatorService flow with fake timers; the idle timer removed the session claim while its reclaim shutdown remained pending, session two claimed and launched on the still-booted UDID, and the reclaim shutdown changed the device to Shutdown with ownership held by session two.
    • Confirmed the end-to-end harness completed within a 45-second timeout with exit code 0 and no startup/runtime errors, and observed the runtime lifecycle where runtime.json was created and then removed after termination.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
packages/host/sim/src/client.ts Adds lazy sidecar spawning, deadlines, correlation, crash recovery, and shutdown; child-generation and write-failure handling can break active requests.
packages/host/sim/src/codec.ts Adds bounded incremental frame encoding and decoding consistent with the sidecar protocol.
packages/host/sim/src/schema.ts Adds validation schemas for simulator requests and results.
packages/host/engine/src/simulator/service.ts Adds device ownership and reclaim policy; asynchronous boot and reclaim transitions can lose or transfer ownership incorrectly.
packages/host/engine/src/engine.ts Connects simulator claims to session teardown and engine shutdown.
apps/daemon/src/sim/backend.ts Adds macOS-only simulator sidecar path resolution for development and packaged runtimes.
apps/desktop/src/main/daemon-supervisor.ts Passes the packaged simulator sidecar path to the managed daemon on macOS.

Sequence Diagram

sequenceDiagram
participant Session
participant Service as SimulatorService
participant Client as SimSidecarClient
participant Sidecar as linkcode-sim
Session->>Service: operation(sessionId, udid)
Service->>Service: validate or create claim
Service->>Client: correlated request
Client->>Sidecar: framed stdin message
Sidecar-->>Client: result or screenshot frame
Client-->>Service: resolve by requestId
Service-->>Session: result
Session->>Service: releaseSession(sessionId)
Service->>Service: schedule idle reclaim
Service->>Client: shutdownDevice(udid)
Loading

Reviews (1): Last reviewed commit: "test(sim): SDK-driven simulator loop int..." | Re-trigger Greptile

Comment on lines +164 to +167
child.stdin.on('error', () => this.onChildGone());
child.stdout.on('error', () => this.onChildGone());
child.on('exit', () => this.onChildGone());
child.on('error', () => this.onChildGone());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale Child Events Kill Replacement

After a crash, a new request can spawn another sidecar before the old child's delayed error, exit, or stdout event arrives. Because every callback mutates shared client state without checking its child, an old event can clear the replacement, reject its pending requests, or feed old bytes into its decoder.

Artifacts

Repro: focused Vitest harness simulating child A, replacement child B, and a delayed stale child-A error

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: focused Vitest configuration used to execute the harness

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: verbose failing run with child A/B request trace, exact command, exit code, and assertion stack

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

// Don't let a pending reply keep the host's event loop alive on its own.
timer.unref();
this.pending.set(requestId, { resolve, reject, timer });
writeFrame(child.stdin, REQUEST, body);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed Writes Wait For Deadline

If stdin fails between ensureChild() and this write, the frame may never reach the sidecar while its pending entry remains registered. The caller then waits for the full operation deadline—up to 210 seconds for boot—instead of receiving the write failure immediately.

Artifacts

Repro: focused controlled-stream Vitest harness

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: focused Vitest configuration

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: verbose runtime trace showing the write failure and unresolved boot request

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +60 to +61
const claim = this.claims.get(udid);
if (claim?.sessionId === sessionId) claim.bootedByService = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Boot Completion Loses Ownership

If the session stops while list() or boot() is pending, releaseSession() removes the claim before bootedByService is set. A successful boot then has no matching claim to update, so the service can treat its own booted simulator as user-owned and leave it running after idle reclaim or engine shutdown.

Artifacts

Repro: deferred-backend lifecycle test harness

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: focused Vitest configuration

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: verbose failing test output and lifecycle trace

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +162 to +164
this.drop(udid);
// Reclaim is best-effort: the device may already be gone (deleted in Xcode, host reboot).
this.backend.shutdownDevice(udid).catch(noop);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Reclaim Frees Device Too Early

The timer drops ownership before shutdownDevice() finishes. Another session can claim and use the same UDID during that gap, after which the previous session's pending shutdown can turn off the newly claimed simulator.

Artifacts

Repro: focused deferred-shutdown SimulatorService test harness

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: focused Vitest configuration used to execute the harness

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: verbose successful test output with chronological ownership-race trace

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant