@solidrt/cli 0.0.52 → 0.0.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/demo/main.ts CHANGED
@@ -1,34 +1,36 @@
1
1
  import { readdirSync } from "node:fs"
2
- import { join, resolve } from "node:path"
2
+ import { join } from "node:path"
3
+ import { fileURLToPath } from "node:url"
3
4
  import { source } from "../lib/args"
4
5
 
5
- // srt demo: the demos the installed @solidrt packages ship. A package's
6
- // demos are ONE project (`<package>/demos/`: package.json, one assets/, and
7
- // src/<name>.tsx per demo), so a demo runs as the project it lives in - this
8
- // file only lists and resolves, and main.ts starts the ordinary dev server
9
- // with its cwd set to that project. Nothing downstream knows about demos.
6
+ // srt demo: the package demos the CLI ships pre-bundled (dist/demos/, built
7
+ // by `make -C packages/cli demos`: the release workflow before publishing, a
8
+ // checkout after editing a demo or its package). dist/demos/<pkg>/ is the
9
+ // package's demos project as the dev server serves it - package.json,
10
+ // assets/, and <slug>/<slug>.srt.js per demo - so a demo runs as the project
11
+ // it lives in: this file only lists and resolves, and main.ts starts the
12
+ // ordinary dev server with its cwd set to that project. Nothing downstream
13
+ // knows about demos, and a demo shows up in the console like any app.
10
14
 
11
15
  export type Demo = { name: string; cwd: string; entry: string }
12
16
 
13
- const SCOPE = join("node_modules", "@solidrt")
17
+ const DEMOS = fileURLToPath(new URL("../../dist/demos", import.meta.url))
14
18
 
15
- /** Every demo installed here, sorted so the printed numbers are stable.
16
- * The cwd and nothing above it - the same rule the server's mode resolution
17
- * follows (server/mode.ts), so this lists what THIS project installed. */
19
+ /** Every demo the CLI ships, sorted so the printed numbers are stable. */
18
20
  function discover(): Demo[] {
19
21
  let demos: Demo[] = []
20
- for (let pkg of names(SCOPE)) {
21
- let dir = join(SCOPE, pkg, "demos")
22
- for (let file of names(join(dir, "src"))) {
23
- if (!file.endsWith(".tsx")) continue
24
- demos.push({ name: `${pkg}/${file.slice(0, -".tsx".length)}`, cwd: resolve(dir), entry: join("src", file) })
22
+ for (let pkg of names(DEMOS)) {
23
+ let cwd = join(DEMOS, pkg)
24
+ for (let slug of names(cwd)) {
25
+ let entry = join(slug, `${slug}.srt.js`)
26
+ if (names(join(cwd, slug)).includes(`${slug}.srt.js`)) demos.push({ name: `${pkg}/${slug}`, cwd, entry })
25
27
  }
26
28
  }
27
29
  return demos
28
30
  }
29
31
 
30
- // A missing folder is the normal case (most packages ship no demos), so it
31
- // reads as an empty one rather than an error.
32
+ // A missing folder reads as an empty one: a checkout without a demos build
33
+ // has none, and assets/ is a sibling of the demo dirs, not one of them.
32
34
  function names(dir: string): string[] {
33
35
  try {
34
36
  return readdirSync(dir).sort()
@@ -45,7 +47,7 @@ function list(demos: Demo[]) {
45
47
  export async function main(): Promise<{ cwd: string; entry: string } | undefined> {
46
48
  let demos = discover()
47
49
  if (demos.length === 0) {
48
- console.error(`No demos installed in ${process.cwd()} (looked in ${SCOPE}/*/demos/src/)`)
50
+ console.error("Demos not built: run make -C packages/cli demos")
49
51
  process.exit(1)
50
52
  }
51
53
 
package/src/init/main.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises"
2
2
  import { basename, dirname, join, resolve } from "node:path"
3
3
  import { source, values } from "../lib/args"
4
- import { multiselect, note, text } from "./prompt"
4
+ import { multiselect, note, text } from "../lib/prompt"
5
5
 
6
6
  const DEFAULT_NAME = "solidrt-app"
7
7
 
@@ -11,7 +11,9 @@ should open whenever the work matches its trigger.
11
11
  - @solidrt/core is the low-level foundation: host intrinsics (`<window>`,
12
12
  `<view>`, `<text>`, the detached `d-*` drawing primitives) with flat props
13
13
  that feed the layout and paint engine directly. An app can be written
14
- entirely at this level.
14
+ entirely at this level. d-* elements go under anything; laid-out elements
15
+ only under laid-out parents (a `<view>` inside a `<d-view>` throws), so a
16
+ component's doc comment says which kind it renders.
15
17
  - Extensions build on core. The first-party ones are @solidrt/components
16
18
  (UI components), @solidrt/2d (2D graphics) and @solidrt/3d (3D graphics).
17
19
  None is privileged - an extension is just functions returning core JSX,
@@ -88,8 +90,10 @@ platform-wide and bite in every app:
88
90
 
89
91
  ## Run / verify
90
92
 
91
- - FIRST check whether a dev server and a client are already running and
92
- build against those; do not start a second `srt run` when one is up.
93
+ - FIRST check whether a dev server and its clients (possibly several) are
94
+ already running and build against those; do not start a second `srt run`
95
+ when one is up. `reload` reaches every connected client; the per-client
96
+ tools are listed in debugging.md.
93
97
  - The dev loop (reload, logs, snapshots, the holds on reload-on-save and on
94
98
  the user's input), typechecking, headless rendering and the MCP tools:
95
99
  node_modules/@solidrt/cli/AGENTS.md and its agents/debugging.md. Read it
@@ -10,14 +10,14 @@
10
10
  "android": "srt android"
11
11
  },
12
12
  "dependencies": {
13
- "@solidrt/core": "0.0.52",
14
- "@solidrt/components": "0.0.52",
15
- "@solidrt/2d": "0.0.52",
16
- "@solidrt/3d": "0.0.52"
13
+ "@solidrt/core": "0.0.54",
14
+ "@solidrt/components": "0.0.54",
15
+ "@solidrt/2d": "0.0.54",
16
+ "@solidrt/3d": "0.0.54"
17
17
  },
18
18
  "devDependencies": {
19
- "@solidrt/cli": "0.0.52",
20
- "@solidrt/flux-types": "0.0.52",
19
+ "@solidrt/cli": "0.0.54",
20
+ "@solidrt/flux-types": "0.0.54",
21
21
  "typescript": "^7"
22
22
  }
23
23
  }
package/src/lib/usage.ts CHANGED
@@ -27,7 +27,7 @@ Commands:
27
27
  run [file] Start dev server + local solidrt-go client
28
28
  server [file] Start dev server only
29
29
  client Start solidrt-go client only
30
- demo [<number>] List the demos the installed packages ship, or run one
30
+ demo [<number>] List the demos the CLI ships, or run one
31
31
  tool [<pkg>/<name>] List the tools the installed packages ship, or run one
32
32
  (everything after the tool name is the tool's own arguments)
33
33
  console Start the dev console: the dev servers on this machine and their clients
package/src/mcp/main.ts CHANGED
@@ -192,7 +192,7 @@ let TOOLS: {
192
192
  name: "list_clients",
193
193
  annotations: READ_ONLY,
194
194
  description:
195
- "List the app clients connected to the SolidRT dev server, and what the server serves. Server fields: `generation` (identity of this server run; client ids, node ids and log cursors are only valid within one, so if it changed since your last call, re-fetch them), `key` and `mode` (the project root, or the single file, this server serves - check it is the app you intend to drive before acting), `entry` (the app source file it rebuilds; `load` moves it), `projectDir` (null for a file served on its own), `userInputMuted` (see mute_user_input) and `watchPaused` (see pause_watch). Per client: `id` (pass it as `client` to the other tools), `platform`, `version` (the runtime's git describe; a -dirty suffix means it was built from uncommitted engine changes), `profile` (debug/release), `capabilities` (the capability names compiled into that runtime), `queries` (the dev-tool query kinds that runtime answers: clock, input, snapshot, tree, ...; a list without \"input\" predates send_input, one without \"clock\" predates set_time_scale/step_frames, an empty list predates the advertisement itself - check it before planning a verification strategy), `stats` (whether its overlay is drawn, see set_stats_overlay), `timeScale` (its clock as it last answered set_time_scale/step_frames: 0 paused, 1 real time; back to 1 on every reload), and what the client knows about itself: `clientDir` (its storage tree on its own machine, `<data-root>/client<N>` for a dev client), `pid`, `execPath` (the runtime binary), `host` (hostname), `os` and `kernel` (the OS as a person names it, e.g. \"Android 15 on Pixel 9 Pro\", and the kernel version), `videoDriver` (SDL's: wayland, x11, android, ...) and `gpu` (vendor, renderer, version as GL reports them) - each null on a runtime that predates it or has no such fact. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
195
+ "List the app clients connected to the SolidRT dev server, and what the server serves. Server fields: `generation` (identity of this server run; client ids, node ids and log cursors are only valid within one, so if it changed since your last call, re-fetch them), `key` and `mode` (the project root, or the single file, this server serves - check it is the app you intend to drive before acting), `entry` (the app source file it rebuilds; `load` moves it), `projectDir` (null for a file served on its own), `userInputMuted` (see mute_user_input) and `watchPaused` (see pause_watch). Per client: `id` (pass it as `client` to the other tools), `platform`, `version` (the runtime's git describe; a -dirty suffix means it was built from uncommitted engine changes), `profile` (debug/release), `capabilities` (the capability names compiled into that runtime), `queries` (the dev-tool query kinds that runtime answers: clock, input, snapshot, tree, ...; a list without \"input\" predates send_input, one without \"clock\" predates set_time_scale/step_frames, an empty list predates the advertisement itself - check it before planning a verification strategy), `stats` (whether its overlay is drawn, see set_stats_overlay), `timeScale` (its clock as it last answered set_time_scale/step_frames: 0 paused, 1 real time; back to 1 on every reload), and what the client knows about itself: `clientDir` (its storage tree on its own machine, `<data-root>/client<N>` for a dev client), `pid`, `execPath` (the runtime binary), `host` (hostname), `os` and `kernel` (the OS as a person names it, e.g. \"Android 15 on Pixel 9 Pro\", and the kernel version), `videoDriver` (SDL's: wayland, x11, android, ...), `refreshRate` (the display's nominal refresh rate in Hz as SDL reported it at connect, what `onFrame`'s `rate` argument carries; null when the client connected before its window existed, a reconnect fills it in) and `gpu` (vendor, renderer, version as GL reports them) - each null on a runtime that predates it or has no such fact. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
196
196
  inputSchema: {},
197
197
  },
198
198
  {
@@ -232,7 +232,7 @@ let TOOLS: {
232
232
  name: "get_stats",
233
233
  annotations: READ_ONLY,
234
234
  description:
235
- "Performance statistics from a running app client. Start with `window`: a summary of the frames rebuilt in the last window_ms (default 5000, max 10000) - frames, p50Ms/p95Ms/maxMs of the JS-thread critical path per frame (render handler + layout + postLayout + paint + hover), slowFrames (frames over the refresh period, periodMs), and `worst`, the single most expensive frame with its ageMs, phase breakdown (jsMs/layoutMs/postLayoutMs/paintMs/hoverMs) and that frame's own layout activity (paraShapes, measureCalls, dirtiedNodes, cacheGets/cacheHits, nodesPainted). This is where jank shows: the smoothed figures below average a one-frame hitch away, the window keeps it. Typical flow: send_input a burst (typing, a drag), then get_stats - `frames: 0` means nothing was rebuilt in the window (idle app), which is different from all-fast. The window also carries rates for the GPU counters when it spans 2+ frames: fenceTimeoutsPerSec, gpuPassesPerFrame (per presented frame), gpuPassIssueMsPerFrame, gpuPassExecMsPerFrame, gpuFrameExecMsPerFrame, rasterCmdMsPerSec - read these instead of differencing the cumulatives yourself. timeMs (client monotonic clock) and frame (present index) stamp the payload so two samples can be differenced. Then the smoothed figures: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped, i.e. words the shared word cache did not have; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and paraShapes near zero on a text change means only the changed words were reshaped), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated), nodesPainted (nodes the latest frame's paint walk entered, 0 when that frame reused the display list - the last rebuild's count is in `window.worst`; mountedNodes minus this is what viewport culling skipped - a long scroller should paint a near-constant number of nodes however long its content). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed at the instant of the query, including the one executing; the frame command blocks on vsync in it, so 1 while frames flow is normal - it is a backlog signal only when it climbs across queries while fps drops; a persistently high idle reading has been seen once on a Windows client and is unexplained, so do not conclude from this field alone), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), gpuPasses/gpuPassIssueMs/gpuPassExecMs (cumulative shader/pipeline target renders on the raster thread, the wall time the raster thread spent issuing them, and the GPU-side time executing them, all in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; issue and exec are different clocks: a pass with a heavy fragment shader is cheap to issue and expensive to execute, so a busy GPU with a small issue figure is normal, and gpuPassExecMs is the number to compare against the refresh period. gpuPassExecMs comes from GL timer queries and lags the pass by a frame or two; it is absent, not 0, when the client's context has none), gpuFrameExecMs (cumulative GPU-side time executing the window draw of each presented frame - the display list plus any window shader, excluding the pass flush and the present - from the same timer queries, same absence rule; gpuFrameExecMsPerFrame in the window is the number to hold against periodMs: near or above it, the GPU is the bottleneck and fenceTimeouts follow, while a healthy jsMs says nothing about it), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
235
+ "Performance statistics from a running app client. Start with `window`: a summary of the frames that changed the picture in the last window_ms (default 5000, max 10000): tree rebuilds, plus GPU content changes presented without one (a layer write, a shader param, an upload: a sprite or shader app's every frame, where the critical path is the render handler alone) - frames, p50Ms/p95Ms/maxMs of the JS-thread critical path per frame (render handler + layout + postLayout + paint + hover), slowFrames (frames over the refresh period, periodMs), and `worst`, the single most expensive frame with its ageMs, phase breakdown (jsMs/layoutMs/postLayoutMs/paintMs/hoverMs) and that frame's own layout activity (paraShapes, measureCalls, dirtiedNodes, cacheGets/cacheHits, nodesPainted). This is where jank shows: the smoothed figures below average a one-frame hitch away, the window keeps it. Typical flow: send_input a burst (typing, a drag), then get_stats - `frames: 0` means nothing changed the picture in the window (idle app), which is different from all-fast. The window also carries rates for the GPU counters when it spans 2+ frames: fenceTimeoutsPerSec, gpuPassesPerFrame (per presented frame), gpuPassIssueMsPerFrame, gpuPassExecMsPerFrame, gpuFrameExecMsPerFrame, rasterCmdMsPerSec - read these instead of differencing the cumulatives yourself. timeMs (client monotonic clock) and frame (present index) stamp the payload so two samples can be differenced. Then the smoothed figures: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped, i.e. words the shared word cache did not have; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and paraShapes near zero on a text change means only the changed words were reshaped), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated), nodesPainted (nodes the latest frame's paint walk entered, 0 when that frame reused the display list - the last rebuild's count is in `window.worst`; mountedNodes minus this is what viewport culling skipped - a long scroller should paint a near-constant number of nodes however long its content). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed at the instant of the query, including the one executing; the frame command blocks on vsync in it, so 1 while frames flow is normal - it is a backlog signal only when it climbs across queries while fps drops; a persistently high idle reading has been seen once on a Windows client and is unexplained, so do not conclude from this field alone), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), gpuPasses/gpuPassIssueMs/gpuPassExecMs (cumulative shader/pipeline target renders on the raster thread, the wall time the raster thread spent issuing them, and the GPU-side time executing them, all in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; issue and exec are different clocks: a pass with a heavy fragment shader is cheap to issue and expensive to execute, so a busy GPU with a small issue figure is normal, and gpuPassExecMs is the number to compare against the refresh period. gpuPassExecMs comes from GL timer queries and lags the pass by a frame or two; it is absent, not 0, when the client's context has none), gpuFrameExecMs (cumulative GPU-side time executing the window draw of each presented frame - the display list plus any window shader, excluding the pass flush and the present - from the same timer queries, same absence rule; gpuFrameExecMsPerFrame in the window is the number to hold against periodMs: near or above it, the GPU is the bottleneck and fenceTimeouts follow, while a healthy jsMs says nothing about it), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm).",
236
236
  inputSchema: {
237
237
  window_ms: z
238
238
  .number()
@@ -321,9 +321,10 @@ let TOOLS: {
321
321
  name: "get_gpu_resources",
322
322
  annotations: READ_ONLY,
323
323
  description:
324
- "Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount plus firstVertex/instanceCount when off their 0/1 defaults, depth, attribute layout, bound sampler texture ids, current uniform values - the most recent writes, which the next frame or readback draws with - plus passes/issueMs/execMs, cumulative per-target render count, raster-thread issue time and GPU-side execution time in whole ms: when get_stats shows gpuPasses or gpuPassExecMs running hot, these attribute the cost to the specific target). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents. Pass `label` to keep only the resources created with exactly that debug label (the create's `label` option) - the stable way to find a target again after a reload, since ids change.",
324
+ "Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount plus firstVertex/instanceCount when off their 0/1 defaults, depth, attribute layout, bound sampler texture ids, current uniform values - the most recent writes, which the next frame or readback draws with - plus passes/issueMs/execMs, cumulative per-target render count, raster-thread issue time and GPU-side execution time in whole ms: when get_stats shows gpuPasses or gpuPassExecMs running hot, these attribute the cost to the specific target). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents. Pass `label` to keep only the resources created with exactly that debug label (the create's `label` option) - the stable way to find a target again after a reload, since ids change. In a draw target's entry list, uniforms wider than a vec4 (matrices) are elided to their length (\"[16]\") so a model's hundred entries stay readable; pass `draw` (an entry id from that list, with `label` to pin the target, since entry ids are per target) to get that one entry's params in full.",
325
325
  inputSchema: {
326
326
  label: z.string().describe("Keep only resources whose create label equals this").optional(),
327
+ draw: z.number().int().describe("Draw entry id whose params are reported in full (pair with label)").optional(),
327
328
  client: CLIENT_ARG,
328
329
  },
329
330
  },
@@ -565,6 +566,7 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
565
566
  case "get_gpu_resources": {
566
567
  let params = new URLSearchParams()
567
568
  if (typeof args?.label === "string") params.set("label", args.label)
569
+ if (typeof args?.draw === "number") params.set("draw", String(args.draw))
568
570
  if (typeof args?.client === "number") params.set("client", String(args.client))
569
571
  let qs = params.toString()
570
572
  return control(`/gpu${qs ? `?${qs}` : ""}`)
@@ -81,6 +81,7 @@ export function clientList(withAddress = false): (ClientEntry & { address?: stri
81
81
  os: info.os,
82
82
  kernel: info.kernel,
83
83
  videoDriver: info.videoDriver,
84
+ refreshRate: info.refreshRate,
84
85
  gpu: info.gpu,
85
86
  ...(withAddress ? { address: ws.remoteAddr ?? null } : {}),
86
87
  }))
@@ -410,9 +411,15 @@ export async function handleControl(req: Request, path: string, query: Map<strin
410
411
  return handleQuery(query, "snapshot", extra)
411
412
  }
412
413
  case "/__control__/gpu": {
413
- // ?label=<text> keeps only resources created with exactly that label.
414
+ // ?label=<text> keeps only resources created with exactly that label;
415
+ // ?draw=<id> reports that draw entry's params in full (matrix-valued
416
+ // params are elided everywhere else).
417
+ let extra: Record<string, unknown> = {}
414
418
  let label = query.get("label")
415
- return handleQuery(query, "gpu", label === undefined ? undefined : { label })
419
+ if (label !== undefined) extra.label = label
420
+ let draw = parseInt(query.get("draw") ?? "", 10)
421
+ if (Number.isFinite(draw)) extra.draw = draw
422
+ return handleQuery(query, "gpu", extra)
416
423
  }
417
424
  case "/__control__/debug": {
418
425
  // GET lists the app's registered debug commands; POST calls one, with
@@ -2,6 +2,8 @@
2
2
 
3
3
  `run` is the everyday command: it starts the dev server and a local client
4
4
  window together, and it is what `bun run dev` calls in a scaffolded project.
5
+ The server outlives the client: closing (or killing) a wedged client keeps
6
+ the server up, and `srt client` reattaches a new one.
5
7
 
6
8
  {{ usage run }}
7
9
 
@@ -248,6 +248,7 @@ function onOpen(ws: ServerWebSocket) {
248
248
  os: null,
249
249
  kernel: null,
250
250
  videoDriver: null,
251
+ refreshRate: null,
251
252
  gpu: null,
252
253
  })
253
254
  console.log(`[cli] Client connected ${ws.remoteAddr ?? "unknown"}`)
@@ -263,10 +264,6 @@ function onClose(ws: ServerWebSocket) {
263
264
  let info = state.clients.get(ws)
264
265
  state.clients.delete(ws)
265
266
  console.log(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`)
266
- // `srt run` lives as long as its clients: once the local client is gone,
267
- // the last remote disconnect ends the server. `srt server` runs until
268
- // stopped.
269
- if (config.client && localClientExited && state.clients.size === 0) shutdown()
270
267
  }
271
268
 
272
269
  function onMessage(ws: ServerWebSocket, msg: string | Uint8Array) {
@@ -290,6 +287,7 @@ function onMessage(ws: ServerWebSocket, msg: string | Uint8Array) {
290
287
  os: text(data.os),
291
288
  kernel: text(data.kernel),
292
289
  videoDriver: text(data.videoDriver),
290
+ refreshRate: typeof data.refreshRate === "number" ? data.refreshRate : null,
293
291
  gpu:
294
292
  data.gpu && typeof data.gpu === "object"
295
293
  ? { vendor: text(data.gpu.vendor) ?? "", renderer: text(data.gpu.renderer) ?? "", version: text(data.gpu.version) ?? "" }
@@ -348,18 +346,21 @@ function bind(port: number): Server {
348
346
  })
349
347
  }
350
348
 
351
- // A bind alone does not prove a port free: with SO_REUSEADDR (the default
352
- // on a listener) Linux lets a loopback bind coexist with another process's
349
+ // A bind alone does not prove a port free: on macOS (BSD SO_REUSEADDR, the
350
+ // default on a listener) a loopback bind coexists with another process's
353
351
  // all-interfaces listener on the same port, and the newcomer then silently
354
- // takes the loopback traffic. So each candidate is dialed first; only a
355
- // refusal means free.
352
+ // takes the loopback traffic; Linux and Windows refuse the second bind. So
353
+ // each candidate is dialed first, and an answer means taken. Anything else
354
+ // is left to the bind: a refusal cannot be waited for, because Windows only
355
+ // reports one after ~2 s of SYN retries, so it reads as filtered within the
356
+ // probe budget and the bind is the truth.
356
357
  async function bindFirstFree(): Promise<Server> {
357
358
  if (config.port !== undefined) return bind(config.port)
358
359
  let candidates: number[] = remembered !== null ? [remembered] : []
359
360
  for (let p = DEFAULT_PORT; p < DEFAULT_PORT + PORT_TRIES; p++) candidates.push(p)
360
361
  let last: unknown = null
361
362
  for (let port of candidates) {
362
- if ((await probe("127.0.0.1", port, { timeoutMs: 200 })) !== "closed") continue
363
+ if ((await probe("127.0.0.1", port, { timeoutMs: 200 })) === "open") continue
363
364
  try {
364
365
  return bind(port)
365
366
  } catch (e) {
@@ -396,7 +397,6 @@ let keepalive = setInterval(() => {
396
397
  let shuttingDown = false
397
398
  let stopRepl = () => {}
398
399
  let localClient: Child | null = null
399
- let localClientExited = false
400
400
  let signalOffs = ["SIGINT", "SIGTERM"].map((signal) =>
401
401
  onSignal(signal, () => {
402
402
  shutdown()
@@ -460,14 +460,11 @@ if (config.client) {
460
460
  localClient = child
461
461
  pump(child.stdout, (line) => console.log(line))
462
462
  pump(child.stderr, (line) => console.error(line))
463
+ // The server outlives its client: a wedged or crashed client is restarted
464
+ // with `srt client` (it reattaches by cwd) without losing the server, its
465
+ // bundle, the watcher or the MCP session. The server stops on quit/signal.
463
466
  child.status().then(() => {
464
467
  localClient = null
465
- localClientExited = true
466
- if (shuttingDown) return
467
- if (state.clients.size === 0) {
468
- shutdown()
469
- } else {
470
- console.log(`[cli] Local client exited, ${state.clients.size} remote client(s) still connected`)
471
- }
468
+ if (!shuttingDown) console.log("[cli] Local client exited; the server keeps running (srt client reattaches)")
472
469
  })
473
470
  }
@@ -35,6 +35,11 @@ export type ClientEntry = {
35
35
  kernel: string | null
36
36
  /** The SDL video driver ("wayland", "x11", "android", "offscreen", ...). */
37
37
  videoDriver: string | null
38
+ /** The display's nominal refresh rate in Hz as SDL reported it when the
39
+ * client connected (what `onFrame`'s `rate` argument carries); null on a
40
+ * runtime that predates it, or on a client that connected before its
41
+ * window existed (a reconnect fills it in). */
42
+ refreshRate: number | null
38
43
  /** The GPU strings as GL reports them; null on a client that connected
39
44
  * before its GL context existed (a reconnect fills it in). */
40
45
  gpu: GpuInfo | null
File without changes