Skip to content

feat: run stim-server service and setup on Windows - #3658

Merged
janicduplessis merged 4 commits into
mainfrom
feat/3650-windows-server-service
Oct 13, 2026
Merged

janicduplessis merged 4 commits into
mainfrom
feat/3650-windows-server-service

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Stacked on #3640 (Windows build workers), which forks from #3631 (Linux service and setup). The diff here is against feat/3616-windows-android-build-worker.

On Windows, stim-server service and stim-server setup refused because there was no service manager backend. A Windows worker had to be started by hand: it did not start at logon, did not restart after a crash, and a client could not update it.

Solution

This adds a third ServiceManager backend, taskscheduler, next to launchd and systemd. service.ts keeps all the policy; the backend only answers what differs. It never needs administrator rights.

I chose the mechanism by probing a real windows-latest runner:

  • Registration works without elevation. Under a restricted token (runas /trustlevel:0x20000), schtasks /Create /XML registered both an interactive task and an S4U task. Register-ScheduledTask failed ("Cannot connect to CIM server. Access denied"), so the backend uses schtasks.
  • Task Scheduler does not restart a task whose process exits. An action that exits 1, with RestartOnFailure set, was not run again within 5 minutes.
  • Stop-ScheduledTask ends only the action process. Children of the action and WMI-created processes survived it.

From those results:

  • The service file is Stim's own JSON, %LOCALAPPDATA%\Stim\services\<label>.json, written by render and edited by rewrite. Logs go to %LOCALAPPDATA%\Stim\Logs\<label>.log, and versions and update outcomes live under %LOCALAPPDATA%\Stim\services\<label>\.
  • The task \Stim\<label> uses an S4U principal with LeastPrivilege, a logon trigger for the user's SID, and normal priority (Task Scheduler's default of 7 is below normal for CPU, I/O and memory). It starts at logon and keeps running after sign-out. Its action runs stim-service-runner.mjs, a supervisor that imports only Node built-ins.
    • install copies the supervisor beside the service, so it does not depend on the server version the service runs.
    • It starts the server from the service file and restarts it 30 s after it exits, matching launchd's throttle and systemd's RestartSec.
    • It records its own pid, the server's pid, the run count and the last exit code in supervisor.json. Its own errors, such as an unreadable service file or a state file it cannot write, go to supervisor.log beside it (and into the last exit code); it then carries on instead of exiting.
  • job trusts that file only when the supervisor pid it names is the EnginePID of the task's running instance, read through the Task Scheduler COM API (GetInstances(1), which includes hidden tasks). Task Scheduler starts that process itself, so this needs no process identity token, mtime or clock comparison. Core's inspectProcessStart is Darwin-only. On the runner, EnginePID matched the supervisor's pid.
  • stop ends the supervisor's process tree with taskkill /T /F, then runs schtasks /End. unload finds port listeners with netstat -ano instead of lsof, treating TCP rows with no foreign address as listeners because netstat localizes the state column.
  • Remote updates outlive the restart they cause, like systemd's --scope. detached starts the updater through a short-lived middle process. taskkill /T follows only live parents, so the updater escapes the tree. A waiting process stays in the tree so the server sees the update end.
  • Other Windows fixes in service.ts:
    • npm runs as node npm-cli.js, because Node will not run the npm.cmd shim without a shell.
    • On Windows only, npm runs from the staging project, and with uploaded packages it uses --no-save. Measured on the runner, npm 10.9.9 otherwise fails in two ways: run from a directory with no package.json, it looks for one there (ENOENT); run from the project, saving the root package.json throws "Cannot read properties of undefined (reading 'spec')". On macOS the same install passes. --no-save with the project as the working directory passes. I found no npm issue that matches.
    • %SystemRoot%\System32\tar.exe replaces /usr/bin/tar, so Git for Windows' GNU tar on PATH is not picked, and stableNode looks for node.exe.
  • setup accepts Windows like Linux: --build only, no Stim Host, no permission steps, and Windows remedies for Tailscale, the JDK and the Android SDK. --device-host is refused ("On Windows, run setup with --build only.").
  • pnpm's node.exe shim. This follows the product decision: Stim keeps PATH as the user set it. install detects when the first node.exe on the service's PATH is not the Node that runs the server, says that a shim such as pnpm's breaks builds with fingerprint-mismatch, and gives the exact --path-prepend that fixes it. The docs say the same.
  • server.workerRoot already accepts Windows absolute paths in feat: let Windows machines take offloaded Android builds #3640.

Known limits:

  • Install and status ran from a restricted token on an administrator account, not from a separate standard account.
  • If the supervisor itself dies, nothing restarts it until the next logon. Task Scheduler restarts only on a failed start.
  • After a reboot, the task starts at the user's logon, not at boot. A boot trigger needs administrator rights.
  • stop is forceful. Windows has no SIGTERM, so the server's shutdown handlers do not run; builds it was running are ended with its tree.

Test plan

All of this ran on a real windows-latest runner (stim-windows-runner). The client was this branch's CLI on a Mac, using a scratch STIM_HOME and a bare React Native 0.87 app. trailhead does not fit Windows path limits; see #3641.

  • Without elevation. From a restricted token (runas /trustlevel:0x20000, which drops the Administrators group), service install --serve installed and started the service (exit 0), and service status read it.
  • Install. service install --serve --path-prepend "C:\Program Files\nodejs" gave stim-server 1.20.2 answers on 127.0.0.1:7787 and Tailnet route: https port 7443, already present. schtasks /Query showed Status: Running, Logon Mode: Interactive/Background and Run As User: runneradmin. Without --path-prepend, install printed the shim note naming setup-pnpm\bin\node.exe.
  • Supervisor errors. With the service file corrupted and the server killed, supervisor.log recorded … is not a service file install wrote., status showed it as the last exit, and once the file was restored the server came back (running (pid 8380), runs: 2).
  • Crash restart. taskkill /PID <server> /F. Status read runs: 1, last exit 1 with no pid, then, about 30 s later, running (pid 7312) with runs: 2.
  • Update, rollback, switch-back.
    • service update --from <packed packages> switched to versions\1.20.2-…, and service rollback switched back.
    • Updating to a package whose server exits at once gave could not be started: … (last exit code 1) … Switched back, and the service was healthy on the previous server.
    • The same update run from the home directory failed before the npm fix and passes with it.
  • Client-driven update.
    • Setup: server.acceptClientBuilds=true on the runner, and a scratch stim-server on the Mac with a control pairing sending machines.update.start.
    • The upload finished and the service restarted (unreachable: 502 while it did). The updater survived the restart: last-update.json reads ok: true with now runs … from versions\1.20.2-f73858d3bc39301c-…, and machines.update.status reports it.
  • Two-machine setup.
    • stim-server setup --client <Mac node> --ticket <t> --expires <…> --build --yes waited for approval. STIM_ACCESS_TICKET=<t> stim doctor --fix on the Mac sent the request, and setup finished with approved … for builds and ready to build.
    • --device-host exited 1 with the refusal above.
    • Doctor then reported stim-windows-runner approved True [].
  • Real offload through the service. stim android --remote-build stim-windows-runner gave built on stim-windows-runner in 9m42s, installed on the Mac's emulator, and bundle loaded. After a reinstall, an incremental build took 21.2s.
  • Uninstall. The task, the service file and the service directory were removed and the log was kept. The route it had not created stayed (Kept the tailscale serve route on https port 7443).
  • Not verified:
    • The logon trigger after a real logoff and logon.
    • Setup's install-from-npm path, because no published release has this code yet. Setup's reuse path was verified.

Unit tests cover the service file's round trip and edits, Windows argument quoting and the task XML, netstat listener parsing, and setup on Windows, with the Linux setup tests parameterized.

Fixes #3650

@janicduplessis janicduplessis added area: server packages/server area: website website/ area: docs docs/ and root documents area: cli packages/stim-cli, including its README and guide labels Oct 13, 2026
@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Fresh review of 1df8155 (diff against feat/3616-windows-android-build-worker). I could not run Windows, so the Windows findings come from reading the code against Task Scheduler, Node and Win32 behavior. macOS and Linux behavior is unchanged except for the npm working directory (item 7).

Medium

  1. The task runs at below-normal priority. service-task.ts:133-140. The XML has no <Priority>, so Task Scheduler uses its default of 7: below-normal CPU priority and low I/O and memory priority. The supervisor, the server and every Gradle or Metro build it starts inherit that, so offloaded builds run slower than the same build from a shell. Fix: add <Priority>4</Priority> (normal) to <Settings> and assert it in the XML test.

  2. whoami breaks with non-ASCII user names. service-manager.ts:335-337. When its output is redirected, whoami writes in the OEM code page. run decodes it as UTF-8, so a name such as josé arrives wrong in <UserId>, and schtasks /Create fails with "No mapping between account names and security IDs". Fix: build the name from ${process.env.USERDOMAIN}\\${userInfo().username}. userInfo is already imported and uses GetUserNameW. Or use the SID from whoami /user /fo csv /nh, which is ASCII and accepted in <UserId>.

  3. When the supervisor fails, its error is lost. service-runner.ts:41-82. Task Scheduler discards the action's stderr, so stim-service-runner: … (a bad service file, an unwritable log, a bad state path) appears nowhere. If it fails at start, install shows only the generic "did not start its supervisor within 30 s". If it fails later, program(file) throws inside the loop, supervise rejects, the supervisor exits, and the service stays down until the next logon. Fix: catch errors for each iteration inside the loop. Record the message as lastExitCode, append it to logPath when that is known (or to supervisor.log beside supervisor.json), sleep for the throttle, and retry.

Low

  1. A stale supervisor.json after a reboot can name a reused pid. service-manager.ts:266-280 and stop at L373-L375. start clears the file, but the logon trigger does not. Between Task Scheduler marking the new instance Running and the new supervisor's first record(), the file still holds the previous boot's pids. In that window stop would run taskkill /T /F on whatever process reuses that pid. The window is short, but it is a kill decision. Fix: the supervisor writes record(null) as its first action. Better, runningSupervisor also compares the state file's mtime with (Get-ScheduledTaskInfo …).LastRunTime in the same PowerShell call.

  2. tar is looked up on PATH. service.ts:799. If Git for Windows' usr\bin comes before System32 on PATH, GNU tar runs. It reads C:\… as host:path and fails ("Cannot connect to C: resolve failed"). This is the same problem that made actions/cache pin its tar. Fix: use join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe'), which is absolute like /usr/bin/tar.

  3. netstat parsing depends on the locale and on IPv4. service.ts:116-130. State is localized (German shows ABHÖREN), so unload finds no listeners there. -p TCP lists IPv4 only, so the [::]:7787 case in the test never occurs. Fix: drop -p TCP, and treat a row as a listener when it has TCP and a foreign address of 0.0.0.0:0 or [::]:0, not when state === 'LISTENING'.

  4. npm now runs with cwd: staging on every platform. service.ts:868-887. --prefix already fixes npm's local prefix, so this is very likely harmless on macOS and Linux. It is a change outside Windows, though, and the PR body does not mention it. Either limit the cwd to win32 or name the change in the body.

  5. Wording. remote-machines.md:506 and :540 says service rollback works "as on a Mac", but Linux works too. AGENTS.md uses "Mac" only where only a Mac works. Use "as on macOS and Linux".

  6. install never ran from a standard account. The test plan ran as runneradmin. The client-driven update exercised Get-ScheduledTask from the S4U LeastPrivilege (filtered) token, which makes a CIM denial unlikely, but the body uses a CIM denial under runas /trustlevel as the reason for choosing schtasks. Worth one sentence in Known limits, or one run as a standard user.

The detached updater is correct. In -e mode process.argv[1] is the first extra argument. The updater's output goes to the update log through fd 2. taskkill /T cannot reach a process whose parent has exited. windowsArgument follows the C runtime quoting rules, the XML escaping and the UTF-16LE BOM are right, and labels are safe inside PowerShell single quotes (LABEL_PATTERN). The --no-save comment names an external npm constraint, so the comment policy keeps it. The tests cover the pure parsers and renderers.

Items 1-3 are actionable, so I am not adding review: ai-approved. I am adding review: needs-human; see the next comment.

@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Human review: new ways to run, persist and kill processes on this machine, and a Stim capability that is new on Windows. Reviewed at 1df8155.

@janicduplessis janicduplessis added the review: needs-human Holds merge until a maintainer reviews the lines named in the Human review: comment label Oct 13, 2026
@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Addressed in df8fd5b: (1) <Priority>4</Priority> with a test; (2) the task's UserId is the SID from whoami /user /fo csv /nh; (3) the supervisor catches each run's errors, writes them to supervisor.log and the last exit code, and retries (verified on the runner with a corrupted service file); (4) the supervisor writes its state first, and the state counts only if written after the task's LastRunTime, read with the Task Scheduler COM API, which also replaces Get-ScheduledTask; (5) %SystemRoot%\\System32\\tar.exe; (6) netstat -ano with listeners matched by a missing foreign address, tested with a localized row; (7) the npm working directory changes only on Windows; (8) wording fixed in the Windows section (the Linux section's line is #3631's); (9) install and status now verified from a restricted token, noted in the body.

@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Fresh review of df8fd5b (diff against feat/3616-windows-android-build-worker). I could not run Windows; the findings come from reading the code against Windows PowerShell 5.1, Task Scheduler COM and Node.

All nine earlier items are fixed as described: <Priority>4</Priority> with a test, the SID from whoami /user /fo csv /nh (the regex takes only the trailing ASCII SID, so the OEM-encoded name column no longer matters, and S-1-12-1-… cloud SIDs match), per-run errors to supervisor.log, record(null) first, %SystemRoot%\System32\tar.exe, netstat -ano keyed on the foreign address, npm cwd on Windows only, the Windows docs wording (line 506 is the Linux section), and the restricted-token note in Known limits. The COM parsing is sound: state 4 is TASK_STATE_RUNNING, a missing task or a never-run task falls through to null, and an unspecified-kind DateTime converts as local time in [DateTimeOffset].

Medium

  1. The new staleness check is the mtime ownership test AGENTS.md rules out, and it has a real failure. service-manager.ts:277-286, used to pick the taskkill /T /F target at :380-384. "Locked state" names "a supervisor lifetime" and says "No mtime, no heartbeat, no staleness threshold". The fix decides whether supervisor.json describes the running instance by comparing its mtime with LastRunTime minus a 2 s slack. The first review suggested this, so this is on the review, not the author. It also fails in practice: LastRunTime is local time, and [DateTimeOffset] resolves an ambiguous local time as standard time. A task that starts in the first pass of the fall-back DST hour gets a lastRun one hour late, so every state file it writes looks stale until the server restarts an hour later. During that hour status reports the service as not running, stop skips the tree kill and relies on schtasks /End, and install/start time out after 30 s.
    Fix without a time comparison: in the same PowerShell call, print the running instance's process id, ($t.GetInstances(0) | Select-Object -First 1).EnginePID, and trust the state file only when its supervisor equals that pid. For an Exec action EnginePID should be the started process (the supervisor); please confirm that on the runner. That drops statSync, TASK_CLOCK_SLACK_MS and LastRunTime. If the maintainer keeps the mtime check instead, the rule's scope needs an amendment in AGENTS.md.

Low

  1. A state-file write failure still ends the supervisor silently and can leave a server untracked. service-runner.ts:51-65. record(null) at :51 and :64 sits outside the try, and record(server) at :56 runs after spawn. On Windows renameSync fails with EPERM when an antivirus scanner holds the fresh .tmp open, and a full disk fails it too. If :56 throws, the catch logs it, then :64 throws again: supervise rejects, the error goes to the discarded stderr, the task ends, and the server just spawned keeps running with no supervisor. If only :56 throws, the next iteration spawns a second server against the same port. Fix: have record catch its own failure and append it to supervisor.log, so the loop always continues.

review: needs-human stays. Item 1 is actionable, so I am not adding review: ai-approved. The Human review: comment is re-posted at this commit in the next comment, because the decision it describes now includes the mtime check.

@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Human review: new ways to run, persist and kill processes on this machine, and a Stim capability that is new on Windows. Reviewed at df8fd5b.

@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Addressed in 322c28d: (1) the mtime/LastRunTime check is gone; the state file counts only when its supervisor pid equals the running instance's EnginePID from GetInstances(1) (hidden tasks need flag 1; GetInstances(0) returned nothing for our hidden task). Verified on the runner: EnginePID=2748 matched supervisor.json's supervisor: 2748, and install, crash restart (runs 2, last exit 1) and uninstall work with it. (2) record catches and logs its own write failures to supervisor.log, so the loop always continues.

@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Fresh review of 322c28d (diff against feat/3616-windows-android-build-worker). I could not run Windows; this reads the code against Windows PowerShell 5.1, Task Scheduler COM and Node. That EnginePID is the Exec action's process (the supervisor) rests on the author's runner check (EnginePID=2748 matched supervisor: 2748).

Both items from the previous review are fixed.

  1. No mtime test remains. service-manager.ts:262-288 trusts supervisor.json only when its supervisor pid is in the set of EnginePIDs of the task's running instances. statSync, TASK_STATE_RUNNING, TASK_CLOCK_SLACK_MS and LastRunTime are gone from the package, README and docs. The edge cases hold: a missing \Stim folder or task makes GetTask throw to stderr with empty stdout, so the set is empty and the result is null; a task that is not running yields no instances; IgnoreNew keeps it to one instance. A state file left from a previous boot cannot match the new instance's pid unless the pid is reused, and then that pid is the live supervisor, so the taskkill /T /F target at :375-380 is always the process Task Scheduler started. Dropping serverAlive on the supervisor is correct, because a running instance's EnginePID is alive by definition; serverAlive is still used for the server pid at :323.
  2. State writes no longer end the supervisor. In service-runner.ts:44-59, record catches its own failure and logs it to supervisor.log. runOnce's synchronous throws (program, openSync, spawn) sit inside the loop's try, so supervise can no longer reject. If record(server) fails after a spawn, the loop still waits for that server's exit, so a second server is no longer started against the same port.

Nothing actionable remains. One note, not blocking: when PowerShell itself fails (for example, Constrained Language Mode refuses New-Object -ComObject), runningSupervisor returns null and drops task.stderr, so start's 30 s timeout points at supervisor.log and schtasks /Query, neither of which shows the cause. This was the same before 322c28d.

I am adding review: ai-approved. review: needs-human stays. The Human review: comment is re-posted at this commit in the next comment, because the ownership rule it describes changed.

@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Human review: new ways to run, persist and kill processes on this machine, and a Stim capability that is new on Windows. Reviewed at 322c28d.

@janicduplessis janicduplessis added the review: ai-approved A fresh AI review found nothing left to fix label Oct 13, 2026
@janicduplessis janicduplessis added review: human-approved A maintainer reviewed the flagged changes and removed review: needs-human Holds merge until a maintainer reviews the lines named in the Human review: comment labels Oct 13, 2026
@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Human review: the maintainer approved the three listed items in chat on 2026-10-13: Stim's ownership of the scheduled task's EnginePID process tree and killing it, the remote updater escaping that tree, and the persistent S4U logon task that lets an approved client update stim-server.

@janicduplessis
janicduplessis force-pushed the feat/3616-windows-android-build-worker branch from e3cc10c to 6ebc3b5 Compare October 13, 2026 18:04
@janicduplessis
janicduplessis force-pushed the feat/3650-windows-server-service branch from 322c28d to 2558fa4 Compare October 13, 2026 18:08
@janicduplessis janicduplessis added review: ai-approved A fresh AI review found nothing left to fix and removed review: ai-approved A fresh AI review found nothing left to fix labels Oct 13, 2026
@janicduplessis
janicduplessis force-pushed the feat/3616-windows-android-build-worker branch from 6ebc3b5 to ae91a04 Compare October 13, 2026 20:29
@janicduplessis
janicduplessis force-pushed the feat/3650-windows-server-service branch from 2558fa4 to 95780c6 Compare October 13, 2026 20:29
@janicduplessis
janicduplessis marked this pull request as ready for review October 13, 2026 20:29
Base automatically changed from feat/3616-windows-android-build-worker to main October 13, 2026 21:11
@janicduplessis
janicduplessis force-pushed the feat/3650-windows-server-service branch from 95780c6 to 7ac4cb5 Compare October 13, 2026 21:14
@janicduplessis
janicduplessis added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 6b10267 Oct 13, 2026
19 checks passed
@janicduplessis
janicduplessis deleted the feat/3650-windows-server-service branch October 13, 2026 21:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: cli packages/stim-cli, including its README and guide area: docs docs/ and root documents area: server packages/server area: website website/ review: ai-approved A fresh AI review found nothing left to fix review: human-approved A maintainer reviewed the flagged changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run stim-server service and setup on Windows

1 participant