authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 00:43:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 00:43:47-07:00
log99e3b721c5255ded20b00ba1798f848bfc0f1f65
treeac689cba47b84e4fe480eb9248655c9639d2fa0d
parentc3b8825bf476cbce5e061c0f99570cf1f6723b89
signature Signed by PGP key B5690EEEBB952194

fix(connect): diagnose incomplete headless server setup (#9602)


11 files changed, 614 insertions(+), 42 deletions(-)

apps/server/src/cli/connect.ts+9-6
......@@ -35,6 +35,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
3535import * as BootService from "../cloud/bootService.ts";
3636import * as CliState from "../cloud/CliState.ts";
3737import * as CliTokenManager from "../cloud/CliTokenManager.ts";
38import { filterRelayResponse } from "../cloud/relayResponse.ts";
3839import {
3940 CLOUD_LINKED_USER_ID,
4041 isAgentActivityPublishingEnabledValue,
......@@ -208,6 +209,8 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b
208209 ` Relay: ${status.relayUrl ?? "not provisioned"}`,
209210 ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`,
210211 ...formatRelayClientStatus(status.relayClient),
212 "",
213 "This is saved setup, not a live connection check. Check the background service with `t3 service status`.",
211214 ...(nextStep ? ["", `Next: ${nextStep}`] : []),
212215 ].join("\n");
213216}
......@@ -346,7 +349,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f
346349 ).pipe(
347350 HttpClientRequest.bearerToken(token.value.accessToken),
348351 httpClient.execute,
349 Effect.flatMap(HttpClientResponse.filterStatusOk),
352 Effect.flatMap(filterRelayResponse),
350353 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
351354 withRelayClientTracing,
352355 );
......@@ -689,17 +692,17 @@ export const connectCommand = Command.make("connect", {
689692 // Show which account was linked so an unexpected identity (an
690693 // authorization code for a different account) is visible before the
691694 // machine is brought online.
692 yield* Console.log(`✓ Connected${connectedAs(linked.identity)}`);
695 yield* Console.log(`✓ Authorized${connectedAs(linked.identity)}`);
693696
694 // Connect itself already succeeded; a boot-service failure must not
695 // fail the command, just tell the user what happened and move on.
697 // Authorization is stored. If service setup fails, preserve it and
698 // show how to run the server manually.
696699 const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);
697700 if (background) {
698701 const platform = yield* HostProcessPlatform;
699702 yield* Console.log(
700703 platform === "darwin"
701 ? "\n✓ Background service ready\n\nT3 Code will stay reachable while you are logged in to this Mac."
702 : "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.",
704 ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup."
705 : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.",
703706 );
704707 return;
705708 }
apps/server/src/cli/service.test.ts+47-1
......@@ -45,10 +45,38 @@ it("reports the installed service version and host paths", () => {
4545it("gives a direct repair command for a stale service", () => {
4646 assert.include(
4747 formatServiceStatus({ ...status, current: false }, "0.0.29"),
48 "Next: Run `npx t3@latest service update`.",
48 "Next: Run `npx t3@0.0.29 service update`.",
4949 );
5050});
5151
52it("explains an incomplete nightly installation and keeps repair on its installed version", () => {
53 const output = formatServiceStatus(
54 {
55 ...status,
56 current: false,
57 installedVersion: "0.0.32-nightly.1",
58 problems: ["linger-disabled", "service-stopped"],
59 },
60 "0.0.32-nightly.1",
61 );
62
63 expect(output).toContain("[linger-disabled]");
64 expect(output).toContain("last login session ends");
65 expect(output).toContain('sudo loginctl enable-linger "$(id -un)"');
66 expect(output).toContain("[service-stopped]");
67 expect(output).toContain("npx t3@0.0.32-nightly.1 service update");
68 expect(output).not.toContain("t3@latest");
69});
70
71it("suggests the newer CLI version when the installed service needs an update", () => {
72 const output = formatServiceStatus(
73 { ...status, current: false, installedVersion: "0.0.28" },
74 "0.0.29",
75 );
76 expect(output).toContain("npx t3@0.0.29 service update");
77 expect(output).not.toContain("npx t3@0.0.28 service update");
78});
79
5280it("explains where the service is supported", () => {
5381 assert.include(
5482 formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"),
......@@ -151,6 +179,15 @@ it.effect.each([
151179 name: "the same version",
152180 state: { ...status, current: false, installedVersion: packageJson.version },
153181 },
182 {
183 name: "an incomplete install of the same version",
184 state: {
185 ...status,
186 current: false,
187 installedVersion: packageJson.version,
188 problems: ["linger-disabled"] as const,
189 },
190 },
154191 { name: "an unknown version", state: { ...status, current: false } },
155192])("installs or repairs $name without an override", ({ state }) =>
156193 Effect.gen(function* () {
......@@ -201,3 +238,12 @@ it.effect("keeps onboarding successful when a newer version appears before insta
201238 expect(ready).toBe(false);
202239 }),
203240);
241
242it.effect("keeps the manual-server fallback when background prerequisites fail", () =>
243 Effect.gen(function* () {
244 const ready = yield* recoverServiceOnboardingOffer(
245 Effect.fail(new BootService.BootServicePrerequisiteError({ problem: "linger-disabled" })),
246 );
247 expect(ready).toBe(false);
248 }),
249);
apps/server/src/cli/service.ts+11-1
......@@ -68,6 +68,9 @@ export function formatServiceStatus(
6868 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
6969 }
7070 const installedVersion = status.installedVersion ?? cliVersion;
71 const problems = (status.problems ?? []).map(
72 (problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem)}`,
73 );
7174 if (
7275 !status.current &&
7376 status.installedVersion !== undefined &&
......@@ -78,6 +81,7 @@ export function formatServiceStatus(
7881 ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`,
7982 ` Unit: ${status.unitPath}`,
8083 ` Logs: ${status.logPath}`,
84 ...problems,
8185 ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`,
8286 ].join("\n");
8387 }
......@@ -86,7 +90,8 @@ export function formatServiceStatus(
8690 ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`,
8791 ` Unit: ${status.unitPath}`,
8892 ` Logs: ${status.logPath}`,
89 ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]),
93 ...problems,
94 ...(status.current ? [] : [` Next: Run \`npx t3@${cliVersion} service update\`.`]),
9095 ].join("\n");
9196}
9297
......@@ -189,6 +194,9 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {
189194 yield* Console.log("T3 Code is already set up to run in the background on this machine.");
190195 return true;
191196 }
197 for (const problem of status.problems ?? []) {
198 yield* Console.warn(`[${problem}] ${BootService.formatBootServiceProblem(problem)}`);
199 }
192200 if (
193201 installed &&
194202 status.installedVersion !== undefined &&
......@@ -239,6 +247,8 @@ export const recoverServiceOnboardingOffer = <R>(
239247 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
240248 BootServiceInstallError: (error) =>
241249 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
250 BootServicePrerequisiteError: (error) =>
251 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
242252 BootServiceUpdatePendingError: (error) =>
243253 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
244254 BootServiceDowngradeRefusedError: (error) =>
apps/server/src/cloud/bootService.test.ts+136-8
......@@ -127,8 +127,17 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
127127
128128 const commands: string[] = [];
129129 const timeouts = new Map<string, unknown>();
130 const control: { failCommand: string | undefined; stateAfterStop?: string } = {
130 const control: {
131 failCommand: string | undefined;
132 stateAfterStop?: string;
133 linger: string;
134 enabled: boolean;
135 active: boolean;
136 } = {
131137 failCommand: undefined,
138 linger: "yes",
139 enabled: true,
140 active: true,
132141 };
133142 const runner = ProcessRunner.ProcessRunner.of({
134143 run: Effect.fn("test.run_boot_service_command")(function* (
......@@ -137,6 +146,11 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
137146 const command = `${input.command} ${input.args.join(" ")}`;
138147 commands.push(command);
139148 timeouts.set(command, input.timeout);
149 const failed = command === control.failCommand;
150 if (!failed && command === "loginctl enable-linger --no-ask-password 501")
151 control.linger = "yes";
152 if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true;
153 if (!failed && command === "systemctl --user restart t3code.service") control.active = true;
140154 if (
141155 control.stateAfterStop !== undefined &&
142156 (command === "systemctl --user stop t3code.service" ||
......@@ -145,9 +159,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
145159 yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie);
146160 }
147161 return {
148 stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
162 stdout:
163 input.args[1] === "--version"
164 ? "t3 v1.2.3\n"
165 : input.command === "loginctl" && input.args[0] === "show-user"
166 ? `${control.linger}\n`
167 : input.args[1] === "is-enabled"
168 ? control.enabled
169 ? "enabled\n"
170 : "disabled\n"
171 : "",
149172 stderr: "",
150 code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0),
173 code: ChildProcessSpawner.ExitCode(
174 failed || (input.args[1] === "is-active" && !control.active) ? 1 : 0,
175 ),
151176 timedOut: false,
152177 stdoutTruncated: false,
153178 stderrTruncated: false,
......@@ -182,10 +207,103 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
182207 ),
183208 );
184209 const service = yield* makeService();
185 return { service, makeService, fs, statePath, commands, timeouts, control };
210 return { service, makeService, fs, statePath, commands, timeouts, control, runtime };
186211});
187212
188213it.layer(NodeServices.layer)("boot service install", (it) => {
214 it.effect(
215 "fails before installing files or validating a runtime when lingering needs an administrator",
216 () =>
217 Effect.gen(function* () {
218 const { service, fs, statePath, commands, control, runtime } = yield* makeHarness();
219 const before = yield* service.status;
220 control.linger = "no";
221 control.failCommand = "loginctl enable-linger --no-ask-password 501";
222 yield* fs.remove(runtime.sentinelPath);
223
224 const error = yield* service.install().pipe(Effect.flip);
225
226 expect(error).toMatchObject({
227 _tag: "BootServicePrerequisiteError",
228 problem: "linger-disabled",
229 });
230 expect(error.message).toContain('sudo loginctl enable-linger "$(id -un)"');
231 expect(error.message).toContain("last login session ends");
232 expect(yield* fs.exists(before.unitPath)).toBe(false);
233 expect(yield* fs.exists(statePath)).toBe(false);
234 expect(
235 commands.some((command) => command.startsWith("npm ") || command.includes("--version")),
236 ).toBe(false);
237 expect(
238 commands.some(
239 (command) => command.includes("daemon-reload") || command.includes("restart"),
240 ),
241 ).toBe(false);
242 expect(yield* fs.readFileString(before.logPath)).toContain("[linger-disabled]");
243 }),
244 );
245
246 it.effect(
247 "detects a partial install and preserves the running service when repair lacks permission",
248 () =>
249 Effect.gen(function* () {
250 const { service, fs, statePath, commands, control } = yield* makeHarness();
251 const plan = yield* service.install();
252 const before = yield* fs.readFileString(statePath);
253 const unit = yield* fs.readFileString(plan.unitPath);
254 control.linger = "no";
255 control.failCommand = "loginctl enable-linger --no-ask-password 501";
256
257 expect(yield* service.status).toMatchObject({
258 current: false,
259 problems: ["linger-disabled"],
260 });
261 commands.length = 0;
262 expect((yield* service.install().pipe(Effect.flip))._tag).toBe(
263 "BootServicePrerequisiteError",
264 );
265 expect(yield* fs.readFileString(statePath)).toBe(before);
266 expect(yield* fs.readFileString(plan.unitPath)).toBe(unit);
267 expect(commands).not.toContain("systemctl --user stop t3code.service");
268 }),
269 );
270
271 it.effect("enables lingering before installing and repairs stopped or disabled services", () =>
272 Effect.gen(function* () {
273 const { service, commands, control } = yield* makeHarness();
274 control.linger = "no";
275 yield* service.install();
276 expect(control.linger).toBe("yes");
277 expect(commands.indexOf("loginctl enable-linger --no-ask-password 501")).toBeLessThan(
278 commands.indexOf("systemctl --user daemon-reload"),
279 );
280
281 control.enabled = false;
282 control.active = false;
283 expect(yield* service.status).toMatchObject({
284 current: false,
285 problems: ["service-disabled", "service-stopped"],
286 });
287 yield* service.install();
288 expect((yield* service.status).current).toBe(true);
289 }),
290 );
291
292 it.effect.each([
293 { command: "systemctl --user show-environment", problem: "user-manager-unavailable" },
294 { command: "loginctl show-user 501 --property=Linger --value", problem: "linger-unavailable" },
295 ])("reports failed prerequisite probes without installing: $command", ({ command, problem }) =>
296 Effect.gen(function* () {
297 const { service, fs, statePath, control } = yield* makeHarness();
298 control.failCommand = command;
299 expect(yield* service.install().pipe(Effect.flip)).toMatchObject({
300 _tag: "BootServicePrerequisiteError",
301 problem,
302 });
303 expect(yield* fs.exists(statePath)).toBe(false);
304 }),
305 );
306
189307 it.effect("installs, reports current state, and uninstalls", () =>
190308 Effect.gen(function* () {
191309 const { service, fs, statePath, commands, timeouts } = yield* makeHarness();
......@@ -286,8 +404,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
286404 expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher);
287405 expect(yield* fs.readFileString(plan.unitPath)).toBe(unit);
288406 expect(
289 commands.filter((command) =>
290 command.startsWith(platform === "linux" ? "systemctl " : "launchctl "),
407 commands.filter(
408 (command) =>
409 command.startsWith(platform === "linux" ? "systemctl " : "launchctl ") &&
410 !command.includes("show-environment"),
291411 ),
292412 ).toEqual(
293413 platform === "linux"
......@@ -351,7 +471,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
351471
352472 const error = yield* service.install().pipe(Effect.flip);
353473 expect(error._tag).toBe("BootServiceCommandError");
354 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([
474 expect(
475 commands.filter(
476 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
477 ),
478 ).toEqual([
355479 "systemctl --user stop t3code.service",
356480 "systemctl --user daemon-reload",
357481 "systemctl --user restart t3code.service",
......@@ -382,7 +506,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
382506 "BootServiceUpdatePendingError",
383507 );
384508 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
385 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([
509 expect(
510 commands.filter(
511 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
512 ),
513 ).toEqual([
386514 "systemctl --user stop t3code.service",
387515 "systemctl --user restart t3code.service",
388516 ]);
apps/server/src/cloud/bootService.ts+113-11
......@@ -235,7 +235,6 @@ function systemdManager(input: {
235235 command: "systemctl",
236236 args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE],
237237 },
238 { step: "enabling lingering for this user", command: "loginctl", args: ["enable-linger"] },
239238 // Start last. No administrative state write occurs after this succeeds.
240239 {
241240 step: "starting the service",
......@@ -409,6 +408,40 @@ export class BootServiceInstallError extends Schema.TaggedErrorClass<BootService
409408 }
410409}
411410
411const BootServiceProblem = Schema.Literals([
412 "user-manager-unavailable",
413 "linger-unavailable",
414 "linger-disabled",
415 "service-disabled",
416 "service-stopped",
417]);
418type BootServiceProblem = typeof BootServiceProblem.Type;
419
420/** These codes and recovery steps are documented in docs/user/background-service.md. */
421export function formatBootServiceProblem(problem: BootServiceProblem): string {
422 switch (problem) {
423 case "user-manager-unavailable":
424 return "Cannot reach the systemd user manager. Run `systemctl --user status` in a login session for the service user. Install your distribution's systemd user-session support if it is missing; do not run T3 with sudo.";
425 case "linger-unavailable":
426 return 'Cannot check whether this user can run services after logout. Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available.';
427 case "linger-disabled":
428 return 'Lingering is disabled. T3 Code will stop when your last login session ends and will not start at boot. Run `sudo loginctl enable-linger "$(id -un)"` on this machine, then retry the service command as your normal user.';
429 case "service-disabled":
430 return "The service is not enabled to start automatically. Run `t3 service update` to repair it.";
431 case "service-stopped":
432 return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service update`.";
433 }
434}
435
436export class BootServicePrerequisiteError extends Schema.TaggedErrorClass<BootServicePrerequisiteError>()(
437 "BootServicePrerequisiteError",
438 { problem: BootServiceProblem, cause: Schema.optional(Schema.Defect()) },
439) {
440 override get message(): string {
441 return `[${this.problem}] ${formatBootServiceProblem(this.problem)}`;
442 }
443}
444
412445export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootServiceUpdatePendingError>()(
413446 "BootServiceUpdatePendingError",
414447 {},
......@@ -434,6 +467,7 @@ export type BootServiceError =
434467 | BootServiceUnsupportedError
435468 | BootServiceCommandError
436469 | BootServiceInstallError
470 | BootServicePrerequisiteError
437471 | BootServiceUpdatePendingError
438472 | BootServiceDowngradeRefusedError;
439473
......@@ -442,6 +476,7 @@ export interface BootServiceStatus {
442476 readonly installed: boolean;
443477 readonly current: boolean;
444478 readonly installedVersion?: string;
479 readonly problems?: ReadonlyArray<BootServiceProblem>;
445480 readonly unitPath: string;
446481 readonly logPath: string;
447482}
......@@ -539,6 +574,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
539574 : Effect.succeed(detectedManager),
540575 );
541576
577 const logFailure = (error: { readonly message: string }) =>
578 DateTime.now.pipe(
579 Effect.flatMap((now) =>
580 fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { flag: "a" }),
581 ),
582 Effect.ignore,
583 );
584
542585 const runStep = Effect.fn("cloud.boot_service.run_step")(function* (
543586 step: string,
544587 command: string,
......@@ -557,16 +600,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
557600 stderrLength: result.stderr.length,
558601 }),
559602 ),
560 Effect.tapError((error) =>
561 DateTime.now.pipe(
562 Effect.flatMap((now) =>
563 fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, {
564 flag: "a",
565 }),
566 ),
567 Effect.ignore,
568 ),
569 ),
603 Effect.tapError(logFailure),
570604 );
571605 });
572606
......@@ -587,6 +621,66 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
587621 { discard: true },
588622 );
589623
624 const probe = (command: string, args: ReadonlyArray<string>) =>
625 runner.run({ command, args, timeout: Duration.seconds(5) }).pipe(Effect.option);
626 const succeeded = (result: Option.Option<ProcessRunner.ProcessRunOutput>) =>
627 Option.isSome(result) && result.value.code === 0;
628 const lingerArgs = [
629 "show-user",
630 ...(uid === undefined ? [] : [String(uid)]),
631 "--property=Linger",
632 "--value",
633 ];
634 const readSystemdProblems = Effect.fn("cloud.boot_service.read_systemd_problems")(function* (
635 includeService: boolean,
636 ) {
637 const [manager, linger] = yield* Effect.all(
638 [probe("systemctl", ["--user", "show-environment"]), probe("loginctl", lingerArgs)],
639 { concurrency: "unbounded" },
640 );
641 const problems: BootServiceProblem[] = [];
642 if (!succeeded(manager)) problems.push("user-manager-unavailable");
643 const lingering = succeeded(linger) && Option.isSome(linger) ? linger.value.stdout.trim() : "";
644 if (lingering !== "yes") {
645 problems.push(lingering === "no" ? "linger-disabled" : "linger-unavailable");
646 }
647 if (includeService && succeeded(manager)) {
648 const [enabled, active] = yield* Effect.all(
649 [
650 probe("systemctl", ["--user", "is-enabled", BOOT_SERVICE_UNIT_FILE]),
651 probe("systemctl", ["--user", "is-active", BOOT_SERVICE_UNIT_FILE]),
652 ],
653 { concurrency: "unbounded" },
654 );
655 if (
656 !succeeded(enabled) ||
657 (Option.isSome(enabled) && enabled.value.stdout.trim() !== "enabled")
658 ) {
659 problems.push("service-disabled");
660 }
661 if (!succeeded(active)) problems.push("service-stopped");
662 }
663 return problems;
664 });
665
666 const requireSystemdPrerequisites = Effect.gen(function* () {
667 const problems = yield* readSystemdProblems(false);
668 const unavailable = problems.find((problem) => problem !== "linger-disabled");
669 if (unavailable) return yield* new BootServicePrerequisiteError({ problem: unavailable });
670 if (!problems.includes("linger-disabled")) return;
671 yield* runStep("enabling lingering for this user", "loginctl", [
672 "enable-linger",
673 "--no-ask-password",
674 ...(uid === undefined ? [] : [String(uid)]),
675 ]).pipe(
676 Effect.mapError(
677 (cause) => new BootServicePrerequisiteError({ problem: "linger-disabled", cause }),
678 ),
679 );
680 const remaining = yield* readSystemdProblems(false);
681 if (remaining[0]) return yield* new BootServicePrerequisiteError({ problem: remaining[0] });
682 });
683
590684 const install = Effect.fn("cloud.boot_service.install")(function* (options?: {
591685 readonly allowDowngrade?: boolean;
592686 }) {
......@@ -595,6 +689,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
595689 .makeDirectory(input.logsDir, { recursive: true })
596690 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
597691
692 // A permissions failure must not leave a partial install or stop a working server.
693 if (manager.kind === "systemd") {
694 yield* requireSystemdPrerequisites.pipe(Effect.tapError(logFailure));
695 }
696
598697 // Prepare every immutable artifact before stopping the installed unit.
599698 yield* ensurePinnedRuntimeInstalled({
600699 baseDir: input.baseDir,
......@@ -743,11 +842,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
743842 detectedManager.kind === "launchd"
744843 ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2")
745844 : contents;
845 const problems = detectedManager.kind === "systemd" ? yield* readSystemdProblems(true) : [];
746846 return {
747847 supported: true,
748848 installed: true,
749849 ...(installedVersion === undefined ? {} : { installedVersion }),
850 problems,
750851 current:
852 problems.length === 0 &&
751853 normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) &&
752854 launcherExists &&
753855 runtimeEntryExists &&
apps/server/src/cloud/http.ts+4-8
......@@ -85,6 +85,7 @@ import {
8585import * as CliTokenManager from "./CliTokenManager.ts";
8686import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
8787import { traceRelayRequest } from "./traceRelayRequest.ts";
88import { filterRelayResponse, relayRequestError } from "./relayResponse.ts";
8889
8990const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-";
9091const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-";
......@@ -526,14 +527,9 @@ const relayClientRequest = <A>(
526527 HttpClientRequest.bearerToken(input.token),
527528 HttpClientRequest.bodyJson(input.payload),
528529 Effect.flatMap(dependencies.httpClient.execute),
529 Effect.flatMap(HttpClientResponse.filterStatusOk),
530 Effect.flatMap(filterRelayResponse),
530531 Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)),
531 Effect.mapError(
532 (cause) =>
533 new EnvironmentHttpInternalServerError({
534 message: `T3 Connect relay request failed: ${String(cause)}`,
535 }),
536 ),
532 Effect.mapError(relayRequestError),
537533 withRelayClientTracing,
538534 );
539535
......@@ -720,7 +716,7 @@ export const releaseManagedTunnelOnShutdown = Effect.fn(
720716 ).pipe(
721717 HttpClientRequest.bearerToken(token.value.accessToken),
722718 dependencies.httpClient.execute,
723 Effect.flatMap(HttpClientResponse.filterStatusOk),
719 Effect.flatMap(filterRelayResponse),
724720 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
725721 withRelayClientTracing,
726722 );
apps/server/src/cloud/relayResponse.test.ts created+145
......@@ -0,0 +1,145 @@
1import { expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4import * as HttpClientError from "effect/unstable/http/HttpClientError";
5
6import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts";
7
8const response = (
9 status: number,
10 body: string | Record<string, unknown>,
11 headers?: Record<string, string>,
12) =>
13 HttpClientResponse.fromWeb(
14 HttpClientRequest.post("https://relay.example.test/v1/client/environment-links"),
15 typeof body === "string"
16 ? new Response(body, { status, ...(headers ? { headers } : {}) })
17 : Response.json(body, { status, ...(headers ? { headers } : {}) }),
18 );
19
20it("reports a transport failure category without exposing request or cause details", () => {
21 const error = relayRequestError(
22 new HttpClientError.HttpClientError({
23 reason: new HttpClientError.TransportError({
24 request: HttpClientRequest.post("https://relay.example.test/link?token=private-token"),
25 description: "private transport details",
26 cause: new Error("private cause details"),
27 }),
28 }),
29 );
30
31 expect(error._tag).toBe("EnvironmentHttpInternalServerError");
32 expect(error.message).toContain("TransportError");
33 expect(error.message).toContain("network connection");
34 expect(error.message).not.toContain("relay.example.test");
35 expect(error.message).not.toContain("private");
36 expect(shouldRetryCloudLink(error)).toBe(true);
37});
38
39it.effect("reports the tunnel limit and relay trace instead of a generic 403", () =>
40 Effect.gen(function* () {
41 const error = yield* filterRelayResponse(
42 response(403, {
43 _tag: "RelayEnvironmentLinkLimitExceededError",
44 code: "environment_link_limit_exceeded",
45 maxTunnels: 3,
46 traceId: "trace-limit",
47 }),
48 ).pipe(Effect.mapError(relayRequestError), Effect.flip);
49
50 expect(error._tag).toBe("EnvironmentHttpForbiddenError");
51 expect(error.message).toContain("at most 3 tunnels");
52 expect(error.message).toContain("Unlink an unused environment");
53 expect(error.message).toContain("Trace ID: trace-limit");
54 }),
55);
56
57it.effect("makes revoked authorization actionable and non-retryable", () =>
58 Effect.gen(function* () {
59 const error = yield* filterRelayResponse(
60 response(401, {
61 _tag: "RelayAuthInvalidError",
62 code: "auth_invalid",
63 reason: "invalid_bearer",
64 traceId: "trace-auth",
65 }),
66 ).pipe(Effect.mapError(relayRequestError), Effect.flip);
67
68 expect(error._tag).toBe("EnvironmentHttpUnauthorizedError");
69 expect(error.message).toContain("invalid_bearer");
70 expect(error.message).toContain("t3 connect login");
71 expect(error.message).toContain("Trace ID: trace-auth");
72 }),
73);
74
75it.effect("reports an unrecognized access denial without printing its response body", () =>
76 Effect.gen(function* () {
77 const error = yield* filterRelayResponse(
78 response(403, "<html>private upstream details</html>", {
79 "content-type": "text/html",
80 "cf-ray": "abcdef1234-IAD",
81 }),
82 ).pipe(Effect.flip);
83
84 expect(error._tag).toBe("EnvironmentHttpForbiddenError");
85 expect(error.message).toContain("HTTP 403");
86 expect(error.message).toContain("proxy or firewall");
87 expect(error.message).toContain("Cloudflare Ray ID: abcdef1234-IAD");
88 expect(error.message).not.toContain("private upstream details");
89 }),
90);
91
92it.effect.each([408, 429, 500, 502, 503, 504])(
93 "keeps transient HTTP %s failures retryable",
94 (status) =>
95 Effect.gen(function* () {
96 const error = yield* filterRelayResponse(response(status, "unavailable")).pipe(Effect.flip);
97 expect(error._tag).toBe("EnvironmentHttpInternalServerError");
98 expect(error.message).toContain(`HTTP ${status}`);
99 }),
100);
101
102it.effect.each([
103 { status: 401, attempts: 1 },
104 { status: 403, attempts: 1 },
105 { status: 429, attempts: 2 },
106 { status: 503, attempts: 2 },
107])("stops rejected startup links but retries temporary failures: $status", ({ status, attempts }) =>
108 Effect.gen(function* () {
109 let requests = 0;
110 const result = yield* Effect.suspend(() => {
111 requests++;
112 return filterRelayResponse(response(requests === 1 ? status : 200, "{}"));
113 }).pipe(
114 Effect.mapError(relayRequestError),
115 Effect.retry({ while: shouldRetryCloudLink, times: 1 }),
116 Effect.result,
117 );
118 expect(requests).toBe(attempts);
119 expect(result._tag).toBe(attempts === 1 ? "Failure" : "Success");
120 }),
121);
122
123it.effect("keeps the relay failure reason and trace when tunnel cleanup fails", () =>
124 Effect.gen(function* () {
125 const error = yield* filterRelayResponse(
126 response(500, {
127 _tag: "RelayInternalError",
128 code: "internal_error",
129 reason: "upstream_unavailable",
130 traceId: "trace-cleanup",
131 }),
132 ).pipe(Effect.flip);
133
134 expect(error._tag).toBe("EnvironmentHttpInternalServerError");
135 expect(error.message).toContain("upstream_unavailable");
136 expect(error.message).toContain("Trace ID: trace-cleanup");
137 }),
138);
139
140it.effect("leaves successful response bodies available to their decoder", () =>
141 Effect.gen(function* () {
142 const result = yield* filterRelayResponse(response(200, '{"ok":true}'));
143 expect(yield* result.json).toEqual({ ok: true });
144 }),
145);
apps/server/src/cloud/relayResponse.ts created+82
......@@ -0,0 +1,82 @@
1import {
2 EnvironmentHttpBadRequestError,
3 EnvironmentHttpConflictError,
4 EnvironmentHttpForbiddenError,
5 EnvironmentHttpInternalServerError,
6 EnvironmentHttpUnauthorizedError,
7} from "@t3tools/contracts";
8import { RelayProtectedError } from "@t3tools/contracts/relay";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
13import { isHttpClientError } from "effect/unstable/http/HttpClientError";
14
15const isRelayResponseError = Schema.is(
16 Schema.Union([
17 EnvironmentHttpBadRequestError,
18 EnvironmentHttpForbiddenError,
19 EnvironmentHttpInternalServerError,
20 EnvironmentHttpUnauthorizedError,
21 ]),
22);
23
24export function relayRequestError(cause: unknown) {
25 return isRelayResponseError(cause)
26 ? cause
27 : new EnvironmentHttpInternalServerError({
28 message: `Could not complete the T3 Connect relay request. ${isHttpClientError(cause) ? `The relay request failed (${cause.reason._tag}).` : "The relay returned an unexpected response."} Check this machine's network connection and relay availability, then retry.`,
29 });
30}
31
32const isPermanentCloudLinkError = Schema.is(
33 Schema.Union([
34 EnvironmentHttpBadRequestError,
35 EnvironmentHttpForbiddenError,
36 EnvironmentHttpUnauthorizedError,
37 EnvironmentHttpConflictError,
38 ]),
39);
40
41export const shouldRetryCloudLink = (error: unknown): boolean => !isPermanentCloudLinkError(error);
42
43function recoveryHint(error: RelayProtectedError): string {
44 switch (error._tag) {
45 case "RelayEnvironmentLinkLimitExceededError":
46 return "Unlink an unused environment in T3 Connect, then restart T3 Code on this machine.";
47 case "RelayAuthInvalidError":
48 return "Run `t3 connect login` to check this machine's authorization. If the stored credential was revoked, sign out with `t3 connect logout`, then run `t3 connect` again. Restart T3 Code after signing in.";
49 case "RelayEnvironmentLinkProofExpiredError":
50 case "RelayEnvironmentLinkProofInvalidError":
51 return "Check this machine's date and time, update T3 Code, then restart it.";
52 default:
53 return "Retry when the relay is available. If this continues, include the trace ID when reporting it.";
54 }
55}
56
57/** Preserve relay diagnostics before converting permanent rejections into non-retryable errors. */
58export const filterRelayResponse = Effect.fn("cloud.filter_relay_response")(function* (
59 response: HttpClientResponse.HttpClientResponse,
60) {
61 if (response.status >= 200 && response.status < 300) return response;
62 const decoded = yield* HttpClientResponse.schemaBodyJson(RelayProtectedError)(response).pipe(
63 Effect.option,
64 );
65 const ray = response.headers["cf-ray"];
66 const requestId = ray && /^[a-zA-Z0-9-]{1,128}$/.test(ray) ? ` Cloudflare Ray ID: ${ray}.` : "";
67 const message = Option.isSome(decoded)
68 ? `T3 Connect: ${decoded.value.message}. ${recoveryHint(decoded.value)} Trace ID: ${decoded.value.traceId}.`
69 : `T3 Connect relay returned HTTP ${response.status} without a recognized error response. Check relay access and any proxy or firewall restrictions, then restart T3 Code.${requestId}`;
70
71 if (response.status === 401) return yield* new EnvironmentHttpUnauthorizedError({ message });
72 if (response.status === 403) return yield* new EnvironmentHttpForbiddenError({ message });
73 if (
74 response.status >= 400 &&
75 response.status < 500 &&
76 response.status !== 408 &&
77 response.status !== 429
78 ) {
79 return yield* new EnvironmentHttpBadRequestError({ message });
80 }
81 return yield* new EnvironmentHttpInternalServerError({ message });
82});
apps/server/src/server.ts+5-6
......@@ -1,4 +1,5 @@
11import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
23import * as Duration from "effect/Duration";
34import * as Deferred from "effect/Deferred";
45import * as Effect from "effect/Effect";
......@@ -106,6 +107,7 @@ import {
106107 releaseManagedTunnelOnShutdown,
107108} from "./cloud/http.ts";
108109import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts";
110import { shouldRetryCloudLink } from "./cloud/relayResponse.ts";
109111import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
110112import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
111113import * as CloudCliState from "./cloud/CliState.ts";
......@@ -675,7 +677,7 @@ export const makeServerLayer = Layer.unwrap(
675677 Effect.catchCause((cause) =>
676678 Effect.logWarning(
677679 "Failed to release the managed tunnel on shutdown; the next link reuses it",
678 { cause },
680 { errors: Cause.prettyErrors(cause).map((error) => error.message) },
679681 ),
680682 ),
681683 Effect.asVoid,
......@@ -707,10 +709,7 @@ export const makeServerLayer = Layer.unwrap(
707709 // reachability after a restart.
708710 yield* reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`).pipe(
709711 Effect.retry({
710 while: (error) =>
711 error._tag !== "EnvironmentHttpBadRequestError" &&
712 error._tag !== "EnvironmentHttpUnauthorizedError" &&
713 error._tag !== "EnvironmentHttpConflictError",
712 while: shouldRetryCloudLink,
714713 schedule: Schedule.exponential("1 second").pipe(
715714 Schedule.modifyDelay(({ duration }) =>
716715 Effect.succeed(Duration.min(duration, Duration.seconds(30))),
......@@ -721,7 +720,7 @@ export const makeServerLayer = Layer.unwrap(
721720 Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")),
722721 Effect.catch((cause) =>
723722 Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", {
724 cause,
723 message: cause.message,
725724 }),
726725 ),
727726 );
docs/user/background-service.md+35-1
......@@ -11,7 +11,8 @@ Install it with the latest T3 Code release:
1111npx t3@latest service install
1212```
1313
14Check whether it is installed:
14Check whether it is installed. On Linux this also checks whether the service is running, enabled
15at startup, and allowed to keep running after logout:
1516
1617```sh
1718npx t3@latest service status
......@@ -58,6 +59,8 @@ updates roll back with the server version. An older launcher may require one loc
5859
5960**Linux** uses a systemd user unit at `~/.config/systemd/user/t3code.service`. The service starts
6061when the machine boots and keeps running after you log out (lingering is enabled during install).
62Setup checks the systemd user manager and enables lingering before installing a runtime or stopping
63an existing service. If that requires administrator permission, setup stops with a recovery command.
6164
6265**macOS** uses a launch agent at `~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. It
6366starts when you log in, not when the Mac boots, and it stops when you log out; macOS has no
......@@ -87,3 +90,34 @@ background. This is only an onboarding shortcut: the service and T3 Connect are
8790
8891Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer
8992want T3 Code to start in the background.
93
94## Troubleshooting
95
96Run `t3 service status` on the server machine. An installed version alone does not mean the service
97is running or will survive logout. Linux status reports these problems:
98
99| Code | What it means | Recovery |
100| -------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
101| `linger-disabled` | The service stops after your last login session ends and does not start at boot. | Run `sudo loginctl enable-linger "$(id -un)"`, then retry setup as your normal user. |
102| `linger-unavailable` | T3 Code could not verify the logout setting. | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. |
103| `user-manager-unavailable` | T3 Code cannot reach your systemd user manager. | Run `systemctl --user status` in a login session for the service user. Install your distribution's systemd user-session support if needed. |
104| `service-disabled` | The service is not enabled to start automatically. | Run the repair command shown by `t3 service status`. |
105| `service-stopped` | The service is installed but is not running. | Read the service log and `systemctl --user status t3code.service`, then run the displayed repair command. |
106
107For an SSH host, run the administrator command in an interactive terminal so sudo can prompt for
108your password:
109
110```sh
111ssh -t your-server 'sudo loginctl enable-linger "$(id -un)"'
112```
113
114Run only the `loginctl` command with sudo. Running `t3` with sudo creates a separate installation and
115Connect identity for root. If an administrator is unavailable, run `t3 serve` in a terminal and
116keep that session open.
117
118The repair command shown by status uses the CLI version, or the installed service version if that
119is newer. An older stable CLI therefore does not recommend downgrading a nightly installation.
120Setup leaves an existing service running if the user-manager or lingering check fails.
121
122`t3 service status` prints the log path. The adjacent `server.trace.ndjson` file contains detailed
123server traces. For failures after authorization, see [T3 Connect troubleshooting](./remote-access.md#t3-connect-troubleshooting).
docs/user/remote-access.md+27
......@@ -2,6 +2,33 @@
22
33Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app.
44
5## T3 Connect troubleshooting
6
7Run `t3 connect` on the server machine to authorize it and optionally install the background service.
8The authorization message means your sign-in was saved. The server must then start and establish its
9relay link before the machine is reachable.
10
11`t3 connect status` reports saved authorization and link configuration, not a live reachability
12check. If the machine appears offline, run `t3 service status` on it and read the displayed log.
13On Linux, a service that works while SSH is open but stops after logout usually has lingering
14disabled. See [background service troubleshooting](./background-service.md#troubleshooting).
15
16Relay errors include the returned reason and trace ID when available:
17
18| Error | Next step |
19| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
20| `environment_link_limit_exceeded` / managed tunnel limit reached | Unlink an unused environment in T3 Connect, then restart T3 Code on this machine. |
21| `auth_invalid` / `invalid_bearer` | Run `t3 connect login`. If the stored credential was revoked, run `t3 connect logout`, then `t3 connect` and restart the server. |
22| Expired or invalid link proof | Check the server's date and time, update T3 Code, and restart it. Include the reason and trace ID if it still fails. |
23| HTTP 403 without a recognized error response | Check relay access and any proxy or firewall restrictions. Include the Cloudflare Ray ID if one was returned; an HTTP status alone does not identify the cause. |
24| HTTP 408, 429, or 5xx | The server retries temporary failures during startup for up to ten minutes. Check network and relay availability; include the trace ID when reporting a persistent failure. |
25
26Authorization and other permanent 4xx rejections stop the startup link attempt immediately.
27After correcting them, restart the server. For the Linux background service, use
28`systemctl --user restart t3code.service`; for a foreground server, stop it and run `t3 serve` again.
29Keep the diagnostic message and trace ID when reporting a problem. Do not post authorization codes,
30pairing URLs, or the contents of the secrets directory.
31
532## Quick Pairing for a Running Server
633
734If a server is already running on this machine, mint a fresh pairing token and QR code without restarting anything: