-
Notifications
You must be signed in to change notification settings - Fork 0
Spawn Primitives
How startDevServer(cwd) actually starts a dev server. In src/servers.ts.
spawn("/bin/zsh", ["-ilc", 'exec "$0" "$@"', cmd, ...args], {
cwd,
detached: true,
stdio: ["ignore", out, out],
});
child.unref();Raycast's GUI-app subprocess inherits a minimal PATH that doesn't include user-installed tools (nvm, pnpm, bun via Homebrew, etc.).
-
-lreads~/.zprofile -
-ireads~/.zshrc
Both flags are required: we don't know which file the user puts their PATH extensions in. Most put it in ~/.zshrc.
Not cd path && cmd. The path goes through the OS spawn syscall, not the shell. Removes one injection surface for paths with quotes/spaces/newlines.
The command string is the fixed literal exec "$0" "$@". The package manager, run, and the script name are passed as positional args after it (cmd, ...args), so zsh binds $0 to cmd and $@ to the rest, then re-quotes them on exec.
We do not build `exec ${cmd} ${args.join(" ")}` (the earlier form). A package.json script key containing a space ("dev server") or a shell metacharacter ("dev; rm -rf ~") would otherwise either break the command or inject. With the argv form the key is run verbatim; it's just an argument, never parsed as shell.
Note this isn't a privilege boundary: npm run <script> executes whatever the repo's script says anyway. It's defense-in-depth plus correctness for unusual-but-legal script names.
startDevServer(cwd, replay?) takes an optional command line, and restartReplay(server) decides whether a restart hands one over. The line is DevServer.command, recorded from ps at detection. It is split on whitespace and passed positionally through the same exec "$0" "$@" form, so an argument that itself contained a space comes apart into two, but nothing in it is ever re-interpreted by the shell; the string form was tried first and rejected in review for exactly that reason.
Which wins, the recorded line or package.json: for anything that is not a Node/Bun server the recorded line wins, because a Django or Rails app that also carries a package.json for its asset build must come back as itself, not as npm run dev. For Node/Bun servers package.json is the project's stated intent and wins over however the last server was started; the recorded line is only the fallback for a bare node server.js in a folder with no dev script.
restartReplay runs before anything is killed and throws when there is nothing to run: Puma and gunicorn overwrite their argv with a status line (puma 6.4.2 (tcp://0.0.0.0:3000) [myapp]), which isProcessTitle recognises by its parentheses, brackets, or colon-terminated first word, and for those restartCommandFor climbs to a replayable candidate ancestor (a bundle exec wrapper, foreman) or records nothing. So a server that cannot be brought back is refused with a message, never taken down. For compiled artifacts (go run, cargo run, dotnet run) the recorded command is the launcher's, found up the process tree, since the temporary binary is gone.
The replay reproduces argv, not environment: a bare python app.py typed under an activated venv restarts under the login shell's PATH. Known and accepted; the failure surfaces in the startup log like any other spawn failure (added 2026-08-16).
The spawned dev server needs to outlive the extension command. Without these, the FDs would close when Raycast tears down the command, killing the server.
killServer(pid) uses SIGKILL. A graceful-shutdown window races the new spawn for the same port. SIGKILL releases the listener immediately. Then we poll process.kill(pid, 0) until ESRCH (max 500ms) to confirm exit before spawning the replacement.
In a restart, the old server's cwd is what we'll watch for the new server at. If we don't kill-then-confirm-exit-then-spawn, the watch effect can see the old server momentarily and declare success before the new one has even started. Order:
- SIGKILL old PIDs (parallel for multi-target)
- Wait for each
process.kill(pid, 0)to throwESRCH -
spawnnew - Watch for new servers to appear in
fetchServers
dev-servers-spawn-<cwdSlug>.log in os.tmpdir(). Keyed by cwd slug so the file stays meaningful after the PID has been replaced. spawnLogPath(cwd) returns this path for error toasts.