Skip to content

feat(server): t3 service runs T3 Code in the background on Windows - #5848

Open
inayayousfi wants to merge 9 commits into
pingdotgg:mainfrom
inayayousfi:agent/windows-service-startup-shortcut
Open

feat(server): t3 service runs T3 Code in the background on Windows#5848
inayayousfi wants to merge 9 commits into
pingdotgg:mainfrom
inayayousfi:agent/windows-service-startup-shortcut

Conversation

@inayayousfi

@inayayousfi inayayousfi commented Aug 9, 2026

Copy link
Copy Markdown

What

t3 service was Linux-only, because it installs a systemd user unit. This adds a Windows path built on a shortcut in the per-user Startup folder.

The shortcut runs PowerShell hidden, PowerShell starts the launcher with no console window, then exits. All four subcommands work: install, status, update, uninstall.

The Linux path does not change

bootService.ts gained exactly two things: a kind tag on the status record, and one message string that now names Windows too. No systemd line moved.

Every addition to the shared launcher is gated on an environment flag the generated logon script sets, which systemd never sets. The platform dispatch sits in the layer builder in cli/service.ts, using the same Layer.unwrap plus dynamic-import shape as server.ts:127, so the Linux module never imports anything Windows.

Three gaps Windows opens, and how they are closed

  • Nothing supervises a Startup folder entry. The launcher restarts its own child, giving up on the same terms as RestartSec=5, StartLimitBurst=5 and StartLimitIntervalSec=300.
  • Nothing captures the launcher's output. The logon script redirects both streams to boot-service.log through cmd.exe.
  • Windows has no SIGTERM. Stopping goes through a request file the launcher watches. A pid file is what tells the CLI whether a launcher is actually running, so an install can never write over one it could not confirm dead. Without that, a slow launcher looks stopped and you end up with two servers on one SQLite database.

Known limitations, all documented

  • It starts at sign-in, not at boot, and stops at sign-out.
  • Stopping is not graceful. The child is terminated without running its shutdown finalizer, so update and uninstall cut in-flight agent work and the server does not release its T3 Connect link.
  • A small window named T3 Code Server blinks once at sign-in.
  • Disabling the entry under Startup apps in Windows Settings is not visible to t3 service status.

Guards

Install refuses when any path contains a percent sign, which cmd.exe expands on its command line even inside quotes, with no escape that works. Left alone it installs cleanly, reports current, and silently never starts. It also refuses to write over an entry switched off in Windows Settings, and warns when Explorer is not the shell.

Testing

pnpm typecheck, pnpm lint and pnpm fmt --check are clean. 151 tests pass across src/cloud, src/cli and the launcher, including 24 for the Windows backend and 8 for the launcher's Windows-only behaviour.

Verified on a real Windows machine and working end to end: install, sign out, sign back in, and the server comes back on its own. The blinked window is named T3 Code Server, the entry shows up under Startup apps, and t3 service uninstall removes it cleanly.


Note

High Risk
Changes how the server is started, stopped, and upgraded on disk (PowerShell, PID files, non-graceful termination), with real risk of duplicate servers on one database if stop/ownership logic fails; mitigated by extensive tests and guards but still core infrastructure.

Overview
Adds Windows support for t3 service, which previously only installed a Linux systemd user unit. On win32, the CLI lazily loads a new backend that installs a Startup folder shortcut (T3 Code Server) running hidden PowerShell, generated startup/shortcut scripts, and the same pinned runtime + launcher layout as Linux.

The service launcher gains an optional self-supervise path (env flag set only by the Windows logon script): it claims a PID file with boot-time stamping and heartbeat, restarts crashed children with systemd-like burst limits, and stops via a stop-request file instead of SIGTERM. The Windows install path refuses paths containing % (cmd expansion) and blocked Startup apps entries, and coordinates stops so installs do not rewrite runtime under a live launcher.

CLI / connect output is platform-aware: status shows Shortcut vs Unit, install/update/onboarding copy explains sign-in vs boot and the expected sign-in window blink, and new boot-service errors surface with actionable messages. Shared BootServiceStatus adds a kind discriminator; Linux systemd behavior is otherwise unchanged aside from messaging and the kind field on status.

Reviewed by Cursor Bugbot for commit a10bb84. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add Windows background service support to the t3 service CLI using a Startup folder shortcut

  • Adds bootServiceWindows.ts, a new Windows-specific BootService backend that installs a PowerShell-driven shortcut in the per-user Startup folder to run the T3 launcher hidden at sign-in.
  • Extends serviceLauncher.ts with a self-supervising mode: the launcher claims a PID file, maintains a heartbeat, restarts crashed children with backoff, and stops cleanly via a stop-request sentinel file.
  • Updates service.ts to select the Windows backend on win32 and adjusts install/update/status output with Windows-specific labels and notices.
  • Adds new error types for Windows-specific failure cases: percent signs in paths (which cmd.exe would expand) and disabled Startup folder entries.
  • BootServiceStatus gains a kind field ('systemd' or 'win32-startup-shortcut') so consumers can distinguish backends.
  • Risk: the self-supervising launcher runs subprocesses and writes PID/sentinel files to disk; recycled PIDs across reboots are mitigated by boot-time comparison but depend on OS uptime accuracy.

Macroscope summarized a10bb84.

The background service was Linux-only, because it installs a systemd user
unit. Windows gets a shortcut in the per-user Startup folder instead. The
shortcut runs PowerShell hidden, PowerShell starts the launcher with no
console window, then exits.

The systemd path is untouched. Every addition to the shared launcher is
gated on a flag the generated logon script sets and systemd never does.

Three gaps had to be covered because Windows has no init system:

- Nothing supervises a Startup folder entry, so the launcher restarts its
  own child on the same terms as RestartSec and StartLimitBurst.
- Nothing captures the launcher's output, so the logon script redirects it
  through cmd.exe.
- Windows has no SIGTERM, so stopping goes through a request file the
  launcher watches. A pid file is what tells the CLI whether a launcher is
  running, so an install can never write over one it could not confirm dead.

Stopping is not graceful on Windows: the child is terminated without its
shutdown finalizer. That, and the fact that the service starts at sign-in
rather than boot and stops at sign-out, are documented.

Install refuses when a path contains a percent sign, which cmd.exe would
expand and silently break, and when the Startup entry is switched off in
Windows Settings.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cde6222f-6f33-405a-9f90-c06577e74b7b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 9, 2026
Comment thread apps/server/src/cloud/bootServiceWindows.ts Outdated
Comment thread apps/server/src/cloud/bootServiceWindows.ts Outdated
Comment thread apps/server/src/serviceLauncher.ts Outdated
Comment thread apps/server/src/serviceLauncher.ts Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

export const offerServiceDuringOnboarding = Effect.gen(function* () {

On Windows, offerServiceDuringOnboarding returns true after a successful install/update, so the connectCommand caller prints "T3 Code will stay reachable after you log out." — but the new Windows prompt explicitly states the service stops at sign-out. Every successful Windows onboarding therefore shows contradictory and incorrect availability guidance. Consider returning platform-aware state or making the caller's success message platform-specific.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/cli/service.ts around line 173:

On Windows, `offerServiceDuringOnboarding` returns `true` after a successful install/update, so the `connectCommand` caller prints `"T3 Code will stay reachable after you log out."` — but the new Windows prompt explicitly states the service stops at sign-out. Every successful Windows onboarding therefore shows contradictory and incorrect availability guidance. Consider returning platform-aware state or making the caller's success message platform-specific.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in dd3bebe, in the caller rather than the return value.

connectCommand now picks the message by platform: Windows gets "T3 Code will start again every time you sign in to Windows." and Linux keeps "T3 Code will stay reachable after you log out." See apps/server/src/cli/connect.ts:697-704.

I kept the boolean return as-is, since it means "background setup is in place", which is true on both platforms. Only the availability sentence differed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread apps/server/src/cloud/bootServiceWindows.ts Outdated

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the new Windows boot-service backend and the touched call sites against the Effect service conventions. Four findings, all in apps/server/src/cloud/bootServiceWindows.ts: the service module is imported with named imports instead of as a namespace, two pure validation failures manufacture an Error only to carry a user-facing message that the generic BootServiceInstallError.message then hides, and one step attribute embeds variable context instead of using structured fields.

Posted via Macroscope — Effect Service Conventions

Comment thread apps/server/src/cloud/bootServiceWindows.ts
Comment thread apps/server/src/cloud/bootServiceWindows.ts
Comment thread apps/server/src/cloud/bootServiceWindows.ts Outdated
Comment thread apps/server/src/cloud/bootServiceWindows.ts Outdated
Comment thread apps/server/src/cloud/bootServiceWindows.ts
Comment thread apps/server/src/cloud/bootServiceWindows.ts
@macroscopeapp

macroscopeapp Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

7 blocking correctness issues found. This PR adds Windows background service support - a significant new feature with complex process coordination logic. Multiple HIGH severity findings identify race conditions in PID file management that could allow two servers against the same database. The scope and unresolved correctness concerns warrant human review.

You can customize Macroscope's approvability policy. Learn more.

Six review findings, all about the same class of failure: something looks
successful while a second server ends up on one SQLite database, or while
nothing runs at all.

- The pid file is claimed exclusively with `wx`, and released only by the
  process that owns it. A Startup shortcut that fires twice now leaves the
  second launcher exiting instead of starting a rival server.
- Failing to publish the pid file no longer continues silently. A live
  launcher without one is invisible to the CLI, which reads absence as
  proof that nothing is running.
- A failed stop-request write fails the operation once a live launcher is
  confirmed, rather than reading as "nothing to stop".
- install and uninstall decide whether to stop from the pid file, not from
  the shortcut. A launcher outlives a shortcut someone deleted by hand.
- `status` reads the shortcut back through the Probe action and compares
  its target and arguments. Comparing the script that wrote the shortcut
  said nothing about a shortcut edited or replaced since.
- Connect onboarding no longer promises Windows users the service survives
  sign-out, which contradicted the prompt they had just accepted.
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Aug 9, 2026
Comment thread apps/server/src/serviceLauncher.ts Outdated
Comment thread apps/server/src/cloud/bootServiceWindows.ts
Comment thread apps/server/src/cloud/bootServiceWindows.ts
…abled flags

Five more review findings, four of them about errors the user never sees.

The percent-sign guard and the disabled-entry guard both wrapped a synthetic
Error inside BootServiceInstallError, whose message is fixed text. The CLI
prints message, so both carefully worded explanations were invisible. They
are now dedicated tagged errors that derive their message from a field, and
the onboarding recovery path prints them directly.

The stop timeout put a pid and a duration into the step string, which every
other step in this module and in bootService.ts keeps as a stable literal.
They are structured attributes on BootServiceCommandError now.

The disabled flag is read from a registry value that outlives the shortcut,
so uninstalling a disabled entry and reinstalling later told the user to
re-enable something Startup apps no longer lists. It is only read when the
shortcut is actually there.

The module imports bootService.ts as a namespace, matching how every other
service-boundary consumer in the repo imports it.

Also fixes a real bug the tests could not see: the probe never emitted the
shortcut readback, because an escaping mistake dropped those lines from the
generated script. The test fake produced them itself, so status looked right
while real Windows would have reported "needs repair" forever. A test now
pins the generated script rather than the fake.
Comment thread apps/server/src/cloud/bootService.ts
Three review findings, all about a pid file that means less than it looks.

A process id is not identity. The file survives a crash or a hard reboot,
and Windows recycles ids freely, so a stale record could name an unrelated
live process. The launcher then refused to start for as long as that
stranger ran, and the CLI waited on it forever. The record now carries the
machine's boot time, so anything written before this boot is obviously
stale on both sides.

A dead launcher also left its stop request behind. The next install would
start a launcher that immediately read that request and stopped itself,
while the install reported success. Cleaning up after a dead launcher now
clears both files.

The percent-sign error told users to set T3CODE_HOME even when the offending
path was the Node executable, which that variable cannot move. The message
gives no relocation advice now; the path label names what to change.
Comment thread apps/server/src/serviceLauncher.ts Outdated
Comment thread apps/server/src/cloud/bootServiceWindows.ts Outdated
Comment thread apps/server/src/serviceLauncher.ts Outdated
Comment thread apps/server/src/cloud/bootServiceWindows.ts
…present

Four review findings.

A boot stamp catches a record from an earlier boot, but not one left by a
launcher that crashed during this boot whose id has since been handed to
someone else. A running launcher now refreshes its record every 15s, and a
record nobody has refreshed for 90s is treated as dead on both sides.

Claiming a stale record was a delete followed by a create, so two launchers
racing could both delete and both claim. An exclusive takeover file picks a
single winner, and it re-checks the holder under that file before deleting.

A start that failed transiently escaped into the fatal path and killed the
launcher outright, which defeats the supervisor over exactly the kind of
failure it exists to absorb. It now spends another attempt from the same
burst, so the burst limit still bounds it.

install stopped the running launcher before checking for a pending remote
update, so the guard meant to protect that update interrupted it first. The
check now runs before anything is terminated.

Also: a transient read error on the pid record no longer counts as proof the
launcher is gone. Only a confirmed not-found does.
Comment thread apps/server/src/serviceLauncher.ts Outdated
Comment thread apps/server/src/serviceLauncher.ts
A launcher that died between creating the takeover file and its finally left
that file behind forever. Every later start then read it as "somebody is
busy", returned false and exited, so the service could never come back and
the only cure was deleting a hidden file by hand.

A takeover covers a stat, a read, a delete and a write, so one that has been
sitting for 30s was abandoned. Recovery renames it away before retrying,
because concurrent renames of one source leave exactly one winner and the
rest find the source already gone. A fresh takeover still means somebody is
genuinely mid-claim, and that case stands down as before.

Also fixes a units bug that made three tests lie. A bare number given to
utimes is a Unix timestamp in seconds, so passing milliseconds set the
modification time to the year 51969. Two staleness tests were therefore
backdating into the future and passing without ever reaching the branch they
name. Both now use seconds, one had to provoke the watcher to reach the
check at all, and the pid test now stops the fake launcher from answering so
a live-looking record would actually fail it.
Comment thread apps/server/src/cloud/bootServiceWindows.ts
Comment thread apps/server/src/serviceLauncher.ts
Comment thread apps/server/src/serviceLauncher.ts
Comment thread apps/server/src/cloud/serviceProtocol.ts
Four review findings, three of which shared a root cause: I let a timestamp
decide something a timestamp cannot decide.

A laptop that sleeps for an hour wakes with a perfectly live launcher whose
last heartbeat is ancient. Reading that as death cleared its record and
started a second server on one SQLite database. An old timestamp now only
makes the answer unknown, and the unknown case is settled by watching for
the next heartbeat instead of guessing. The CLI gets that for free: its stop
wait is already longer than several heartbeats, so a record that never moves
during the wait belongs to a recycled process id, and one that does move is
a live launcher that will not stop and must not be written over.

That also settles the boot tolerance concern. A reboot inside the tolerance
window can still look like the same boot, but the record is then quiet and
the probe reaches the right answer anyway.

The first start had no burst retry, only restarts did, so a transient spawn
failure at sign-in left the service down until the next one. Both paths now
share one retry.

Windows has no KillMode=mixed, so a launcher killed outright leaves its
server behind. The record now names the child, and whoever takes a leftover
record over puts that orphan down before starting its own.
}
if (!processIsAlive(childPid)) return;
try {
process.kill(childPid, "SIGKILL");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High src/serviceLauncher.ts:594

#terminateOrphanedChild sends SIGKILL to whatever process currently holds the recorded childPid. When the original server died during this boot and Windows reused that PID for an unrelated process, the takeover path kills that unrelated process. The boot-time check only validates when the record was written — it does not prove the process now holding childPid is the original server. Consider tracking a child-process identifier that survives PID reuse, or skipping the orphan kill when the current child is still alive under this launcher.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serviceLauncher.ts around line 594:

`#terminateOrphanedChild` sends `SIGKILL` to whatever process currently holds the recorded `childPid`. When the original server died during this boot and Windows reused that PID for an unrelated process, the takeover path kills that unrelated process. The boot-time check only validates when the record was written — it does not prove the process now holding `childPid` is the original server. Consider tracking a child-process identifier that survives PID reuse, or skipping the orphan kill when the current child is still alive under this launcher.

* modification time proves this launcher is still here. Doubling as the
* heartbeat keeps one writer for one file.
*/
async #refreshPidFile(): Promise<void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High src/serviceLauncher.ts:575

#refreshPidFile overwrites the shared pid file without checking that this launcher still owns it. If this launcher's event loop is paused longer than the heartbeat probe, a replacement launcher takes over; when the old launcher resumes, its heartbeat interval rewrites the replacement's presence record and both launchers continue running their own children against the same SQLite database. A pending async refresh can also recreate the file after #releasePidFile has already cleaned it up during shutdown.

The heartbeat should verify ownership (e.g. re-read the file and confirm pid === process.pid) before rewriting it, and skip the write when ownership has been lost.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serviceLauncher.ts around line 575:

`#refreshPidFile` overwrites the shared pid file without checking that this launcher still owns it. If this launcher's event loop is paused longer than the heartbeat probe, a replacement launcher takes over; when the old launcher resumes, its heartbeat interval rewrites the replacement's presence record and both launchers continue running their own children against the same SQLite database. A pending async refresh can also recreate the file after `#releasePidFile` has already cleaned it up during shutdown.

The heartbeat should verify ownership (e.g. re-read the file and confirm `pid === process.pid`) before rewriting it, and skip the write when ownership has been lost.

@@ -423,6 +875,7 @@ export class Launcher {
process: child,
};
this.#child = managed;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High src/serviceLauncher.ts:877

The child's exit and message listeners are registered after await this.#refreshPidFile(), so a child that crashes or sends its prepared IPC message during that await loses both events permanently. The launcher retains a dead #child and never restarts, or treats a healthy trial as a prepared-timeout and rolls the update back. Register the exit and message listeners before #refreshPidFile so no event can slip past them.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serviceLauncher.ts around line 877:

The child's `exit` and `message` listeners are registered after `await this.#refreshPidFile()`, so a child that crashes or sends its `prepared` IPC message during that await loses both events permanently. The launcher retains a dead `#child` and never restarts, or treats a healthy trial as a `prepared-timeout` and rolls the update back. Register the `exit` and `message` listeners before `#refreshPidFile` so no event can slip past them.

Comment on lines +482 to +483
const timeoutMs = Duration.toMillis(input.stopRequestTimeout ?? STOP_REQUEST_TIMEOUT);
const pollMs = Math.min(timeoutMs, Duration.toMillis(STOP_REQUEST_ACK_POLL));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High cloud/bootServiceWindows.ts:482

A zero-valued stopRequestTimeout makes pollMs zero, so timeoutMs / pollMs is NaN and Math.max(1, NaN) returns NaN. The for loop condition attempt < NaN is immediately false, so the polling loop is skipped entirely. The code then reads the PID file mtime, which normally still equals the just-read recordMtimeBefore, classifies the confirmed-live launcher as a recycled PID, deletes its PID and stop-request files, and lets installation start a second server on the same database. Consider clamping pollMs to a minimum positive value so zero timeout still produces at least one poll attempt, or validating the input timeout is positive.

    const timeoutMs = Duration.toMillis(input.stopRequestTimeout ?? STOP_REQUEST_TIMEOUT);
-    const pollMs = Math.min(timeoutMs, Duration.toMillis(STOP_REQUEST_ACK_POLL));
+    const pollMs = Math.max(1, Math.min(timeoutMs, Duration.toMillis(STOP_REQUEST_ACK_POLL)));
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/cloud/bootServiceWindows.ts around lines 482-483:

A zero-valued `stopRequestTimeout` makes `pollMs` zero, so `timeoutMs / pollMs` is `NaN` and `Math.max(1, NaN)` returns `NaN`. The `for` loop condition `attempt < NaN` is immediately false, so the polling loop is skipped entirely. The code then reads the PID file mtime, which normally still equals the just-read `recordMtimeBefore`, classifies the confirmed-live launcher as a recycled PID, deletes its PID and stop-request files, and lets installation start a second server on the same database. Consider clamping `pollMs` to a minimum positive value so zero timeout still produces at least one poll attempt, or validating the input timeout is positive.

Comment on lines +341 to +347
const pidRecordMtime = async (target: string): Promise<number | undefined> => {
try {
return (await NodeFSP.stat(target)).mtimeMs;
} catch {
return undefined;
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High src/serviceLauncher.ts:341

pidRecordMtime converts every stat failure into undefined, not just ENOENT. In pidRecordIsBeingRefreshed, when the second stat call fails with a transient I/O or permission error, the function returns false — the same result as a confirmed missing record. In #pidFileHolderIsAlive, this makes a verified same-boot, live launcher look dead, so takeover proceeds to kill its recorded child and remove its PID file. That lets a second launcher start against the same database. Only ENOENT should establish absence; other stat failures must be propagated or conservatively treated as alive.

Suggested change
const pidRecordMtime = async (target: string): Promise<number | undefined> => {
try {
return (await NodeFSP.stat(target)).mtimeMs;
} catch {
return undefined;
}
};
+const pidRecordMtime = async (target: string): Promise<number | undefined> => {
+ try {
+ return (await NodeFSP.stat(target)).mtimeMs;
+ } catch (cause) {
+ if ((cause as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return undefined;
+ throw cause;
+ }
+};
Also found in 1 other location(s)

apps/server/src/cloud/bootServiceWindows.ts:497

pidRecordMtime converts every fs.stat(pidPath) failure into Option.none, and the timeout path treats Option.none(recordMtimeAfter) as proof that the PID record never refreshed. A transient sharing/permission/I/O error during this final stat therefore causes lines 500-502 to delete the live launcher's coordination files and return false; installation then rewrites and starts the service while the confirmed launcher may still be running, risking two servers against the same database. Only a confirmed NotFound should establish absence; other stat failures must abort safely.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serviceLauncher.ts around lines 341-347:

`pidRecordMtime` converts every `stat` failure into `undefined`, not just `ENOENT`. In `pidRecordIsBeingRefreshed`, when the second `stat` call fails with a transient I/O or permission error, the function returns `false` — the same result as a confirmed missing record. In `#pidFileHolderIsAlive`, this makes a verified same-boot, live launcher look dead, so takeover proceeds to kill its recorded child and remove its PID file. That lets a second launcher start against the same database. Only `ENOENT` should establish absence; other `stat` failures must be propagated or conservatively treated as alive.

Also found in 1 other location(s):
- apps/server/src/cloud/bootServiceWindows.ts:497 -- `pidRecordMtime` converts every `fs.stat(pidPath)` failure into `Option.none`, and the timeout path treats `Option.none(recordMtimeAfter)` as proof that the PID record never refreshed. A transient sharing/permission/I/O error during this final stat therefore causes lines 500-502 to delete the live launcher's coordination files and return `false`; installation then rewrites and starts the service while the confirmed launcher may still be running, risking two servers against the same database. Only a confirmed `NotFound` should establish absence; other stat failures must abort safely.

// Before anything is stopped. This guard exists to protect an in-flight
// remote update, and terminating the launcher first would interrupt the
// very thing it is guarding.
const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High cloud/bootServiceWindows.ts:610

The pending-update guard at fs.readFileString(statePath).pipe(Effect.option) turns every read failure into None, not just NotFound. A transient sharing violation or I/O error is therefore treated as "no state file," bypassing serviceStateHasPendingUpdate. The install then stops the launcher and overwrites service-state.json while a remote update is in flight. Consider catching only NotFound as absent and propagating other read errors as a BootServiceInstallError, matching the pattern already used in requestStop.

-    const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);
+    const previousStateText = yield* fs.readFileString(statePath).pipe(
+      Effect.map(Option.some),
+      Effect.catch((error) =>
+        error.reason._tag === "NotFound"
+          ? Effect.succeed(Option.none<string>())
+          : Effect.fail(new BootService.BootServiceInstallError({ cause: error })),
+      ),
+    );
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/cloud/bootServiceWindows.ts around line 610:

The pending-update guard at `fs.readFileString(statePath).pipe(Effect.option)` turns every read failure into `None`, not just `NotFound`. A transient sharing violation or I/O error is therefore treated as "no state file," bypassing `serviceStateHasPendingUpdate`. The install then stops the launcher and overwrites `service-state.json` while a remote update is in flight. Consider catching only `NotFound` as absent and propagating other read errors as a `BootServiceInstallError`, matching the pattern already used in `requestStop`.

if (!this.#selfSupervise) return;
await NodeFSP.writeFile(pidFilePath(this.#baseDir), this.#presenceRecord(), {
mode: 0o600,
}).catch(() => undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Heartbeat rewrite races pid ownership

High Severity

Replacing utimes heartbeats with unconditional writeFile in #refreshPidFile makes the pid record non-atomic and unowned. A concurrent requestStop read can observe a truncated file, decode as missing, and clear a live launcher's record; the same write can also recreate or overwrite another launcher's claim. Either path can put two servers on one SQLite database during install/update.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d3fcacc. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

There are 4 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a10bb84. Configure here.

if (neverRefreshed) {
yield* fs.remove(pidPath, { force: true }).pipe(Effect.ignore);
yield* fs.remove(stopRequestPath, { force: true }).pipe(Effect.ignore);
return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Orphan server survives pid clear

High Severity

requestStop clears a leftover pid record when the launcher looks dead or the heartbeat never moves, but it never terminates childPid. Launcher takeover does kill that orphan before claiming. After the CLI deletes the record, the next install starts a new launcher with an empty pid file, so #terminateOrphanedChild never runs and a crashed launcher’s server can keep sharing the same SQLite database.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a10bb84. Configure here.

`$shortcut.Description = ${quotePowerShellLiteral(`${SHORTCUT_NAME}, started at sign-in`)}`,
"# Minimized, because PowerShell paints a console before it hides itself.",
"$shortcut.WindowStyle = 7",
"$shortcut.Save()",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Disabled Startup state outlives uninstall

High Severity

Probe only treats StartupApproved as disabled while the .lnk exists, and neither uninstall nor Install clears or re-enables that registry value. After a user disables the entry, uninstalls, and installs again, the new shortcut is recreated under a leftover disabled flag, so install can report success while sign-in never starts the service.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a10bb84. Configure here.

const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);
if (Option.isSome(previousStateText) && serviceStateHasPendingUpdate(previousStateText.value)) {
return yield* new BootService.BootServiceUpdatePendingError();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pending update guard swallows read errors

Medium Severity

The pending-update check reads service-state.json with Effect.option, so any non-success read becomes “no pending update.” That undermines the before-stop ordering: a sharing violation or similar failure while an update is in flight skips BootServiceUpdatePendingError and lets requestStop terminate the launcher mid-update.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a10bb84. Configure here.

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

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant