Skip to content

Commit a4427f9

Browse files
Merge branch 'main' into factory/issue-28247
2 parents 10e617c + 5922408 commit a4427f9

124 files changed

Lines changed: 4264 additions & 485 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/desktop/src/app/DesktopApp.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ const bootstrap = Effect.gen(function* () {
227227
// A backend started while another server holds this home's lock can only exit with code 78.
228228
if (
229229
!(settings.wslOnly && settings.wslBackendEnabled) &&
230-
(yield* DesktopHomeOwner.isHomeOwnershipLocked())
230+
!(yield* DesktopHomeOwner.awaitHomeOwnershipRelease())
231231
) {
232232
const owner = yield* DesktopHomeOwner.readLiveHomeOwner();
233233
yield* logBootstrapWarning("bootstrap found another server owning the T3 home", {

‎apps/desktop/src/backend/DesktopHomeOwner.test.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import * as NodeSqlite from "node:sqlite";
33
import * as NodeServices from "@effect/platform-node/NodeServices";
44
import { assert, describe, it } from "@effect/vitest";
55
import * as Effect from "effect/Effect";
6+
import * as Fiber from "effect/Fiber";
67
import * as FileSystem from "effect/FileSystem";
78
import * as Layer from "effect/Layer";
89
import * as Option from "effect/Option";
910
import * as Path from "effect/Path";
11+
import * as TestClock from "effect/testing/TestClock";
1012

1113
import * as DesktopConfig from "../app/DesktopConfig.ts";
1214
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
@@ -94,16 +96,26 @@ describe("DesktopHomeOwner.readLiveHomeOwner", () => {
9496
);
9597
});
9698

97-
describe("DesktopHomeOwner.isHomeOwnershipLocked", () => {
98-
it.effect("reports the ownership lock only while a server holds it", () =>
99+
describe("DesktopHomeOwner.awaitHomeOwnershipRelease", () => {
100+
it.effect("waits for a shutting-down owner to release the lock, then gives up", () =>
99101
withTempHome((userdata) =>
100102
Effect.gen(function* () {
101-
assert.isFalse(yield* DesktopHomeOwner.isHomeOwnershipLocked());
103+
assert.isTrue(yield* DesktopHomeOwner.awaitHomeOwnershipRelease());
104+
102105
const lock = new NodeSqlite.DatabaseSync(`${userdata}/server-owner.sqlite`);
103106
lock.exec("BEGIN EXCLUSIVE;");
104-
assert.isTrue(yield* DesktopHomeOwner.isHomeOwnershipLocked());
107+
const released = yield* Effect.forkChild(DesktopHomeOwner.awaitHomeOwnershipRelease());
108+
yield* TestClock.adjust("5 seconds");
105109
lock.close();
106-
assert.isFalse(yield* DesktopHomeOwner.isHomeOwnershipLocked());
110+
yield* TestClock.adjust("1 second");
111+
assert.isTrue(yield* Fiber.join(released));
112+
113+
const held = new NodeSqlite.DatabaseSync(`${userdata}/server-owner.sqlite`);
114+
held.exec("BEGIN EXCLUSIVE;");
115+
const owned = yield* Effect.forkChild(DesktopHomeOwner.awaitHomeOwnershipRelease());
116+
yield* TestClock.adjust("1 minute");
117+
assert.isFalse(yield* Fiber.join(owned));
118+
held.close();
107119
}),
108120
),
109121
);

‎apps/desktop/src/backend/DesktopHomeOwner.ts‎

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
// @effect-diagnostics nodeBuiltinImport:off - Probes the server's native SQLite ownership lock.
22
import * as NodeSqlite from "node:sqlite";
33

4+
import * as Duration from "effect/Duration";
45
import * as Effect from "effect/Effect";
56
import * as FileSystem from "effect/FileSystem";
67
import * as Option from "effect/Option";
78
import * as Path from "effect/Path";
89
import * as Schema from "effect/Schema";
910

1011
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12+
import { BACKEND_QUIT_STOP_TIMEOUT } from "./DesktopBackendManager.ts";
1113

1214
// The fields of the server's `server-runtime.json` that identify the owner of a T3 home.
1315
const HomeOwnerRuntimeState = Schema.Struct({
@@ -35,27 +37,43 @@ const isProcessAlive = (pid: number): boolean => {
3537
* Whether a server holds the T3 home's ownership lock (an exclusive transaction on
3638
* `server-owner.sqlite`). A read-only read is refused with SQLITE_BUSY only while it is held.
3739
*/
38-
export const isHomeOwnershipLocked = Effect.fn("DesktopHomeOwner.isHomeOwnershipLocked")(
39-
function* () {
40-
const path = yield* Path.Path;
41-
const { stateDir } = yield* DesktopEnvironment.DesktopEnvironment;
42-
const lockPath = path.join(stateDir, "server-owner.sqlite");
43-
return yield* Effect.sync(() => {
40+
const isHomeOwnershipLocked = Effect.fn("DesktopHomeOwner.isHomeOwnershipLocked")(function* () {
41+
const path = yield* Path.Path;
42+
const { stateDir } = yield* DesktopEnvironment.DesktopEnvironment;
43+
const lockPath = path.join(stateDir, "server-owner.sqlite");
44+
return yield* Effect.sync(() => {
45+
try {
46+
const db = new NodeSqlite.DatabaseSync(lockPath, { readOnly: true });
4447
try {
45-
const db = new NodeSqlite.DatabaseSync(lockPath, { readOnly: true });
46-
try {
47-
db.exec("PRAGMA busy_timeout = 0");
48-
db.prepare("SELECT count(*) FROM sqlite_master").get();
49-
return false;
50-
} finally {
51-
db.close();
52-
}
53-
} catch (cause) {
54-
// Anything but SQLITE_BUSY (a missing file included) leaves the decision to the backend.
55-
return cause instanceof Error && "errcode" in cause && cause.errcode === 5;
48+
db.exec("PRAGMA busy_timeout = 0");
49+
db.prepare("SELECT count(*) FROM sqlite_master").get();
50+
return false;
51+
} finally {
52+
db.close();
5653
}
57-
});
54+
} catch (cause) {
55+
// Anything but SQLITE_BUSY (a missing file included) leaves the decision to the backend.
56+
return cause instanceof Error && "errcode" in cause && cause.errcode === 5;
57+
}
58+
});
59+
});
60+
61+
// A quitting app's backend holds the lock until it exits, which can outlast the relaunch of an update.
62+
const HOME_OWNERSHIP_RELEASE_GRACE = Duration.sum(BACKEND_QUIT_STOP_TIMEOUT, Duration.seconds(3));
63+
const HOME_OWNERSHIP_RELEASE_POLL_INTERVAL = Duration.millis(250);
64+
65+
/** Whether the ownership lock is free, or frees within the grace a shutting-down server needs. */
66+
export const awaitHomeOwnershipRelease = Effect.fn("DesktopHomeOwner.awaitHomeOwnershipRelease")(
67+
function* () {
68+
while (yield* isHomeOwnershipLocked()) {
69+
yield* Effect.sleep(HOME_OWNERSHIP_RELEASE_POLL_INTERVAL);
70+
}
71+
return true;
5872
},
73+
Effect.timeoutOrElse({
74+
duration: HOME_OWNERSHIP_RELEASE_GRACE,
75+
orElse: () => Effect.succeed(false),
76+
}),
5977
);
6078

6179
/** The live server recorded as owning the desktop's T3 home. Only call once the ownership lock is known held: a live PID alone may be reused. */

‎apps/desktop/src/updates/DesktopUpdates.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,32 @@ describe("DesktopUpdates", () => {
932932
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
933933
});
934934

935+
it.effect("keeps the current track's offers when a track switch fails to save", () => {
936+
const harness = makeHarness({
937+
setUpdateChannelError: new DesktopAppSettings.DesktopSettingsWriteError({
938+
operation: "replace-settings-file",
939+
path: "desktop-settings.json",
940+
cause: new Error("rename failed"),
941+
}),
942+
});
943+
944+
return Effect.scoped(
945+
Effect.gen(function* () {
946+
const updates = yield* DesktopUpdates.DesktopUpdates;
947+
yield* updates.configure;
948+
const error = yield* updates
949+
.setChannel("nightly", { minimumVersion: "1.2.5-nightly.20261028.1" })
950+
.pipe(Effect.flip);
951+
assert.equal(error._tag, "DesktopUpdateChannelPersistenceError");
952+
assert.equal((yield* updates.getState).channel, "latest");
953+
954+
harness.emit("update-available", { version: "1.2.4" });
955+
yield* flushCallbacks;
956+
assert.equal((yield* updates.getState).availableVersion, "1.2.4");
957+
}),
958+
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
959+
});
960+
935961
it.effect("keeps a saved Stable track across a restart without offering an older release", () => {
936962
// A Nightly build whose switch to Stable did not finish before the app quit.
937963
const harness = makeHarness({ appVersion: "1.2.4-nightly.20261027.1", savedChannel: "latest" });

‎apps/desktop/src/updates/DesktopUpdates.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,7 +1229,10 @@ export const make = Effect.gen(function* () {
12291229
}
12301230

12311231
return yield* Effect.gen(function* () {
1232-
yield* Ref.set(releaseFloorRef, Option.fromNullishOr(options?.minimumVersion));
1232+
const previousFloor = yield* Ref.getAndSet(
1233+
releaseFloorRef,
1234+
Option.fromNullishOr(options?.minimumVersion),
1235+
);
12331236
const state = yield* Ref.get(updateStateRef);
12341237
const retained = state.downloadedVersion ?? state.availableVersion;
12351238
// A build from another track re-checks, so retrying a track switch finds an older release.
@@ -1242,13 +1245,12 @@ export const make = Effect.gen(function* () {
12421245
return state;
12431246
}
12441247

1245-
yield* desktopSettings
1246-
.setUpdateChannel(nextChannel)
1247-
.pipe(
1248-
Effect.mapError(
1249-
(cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }),
1250-
),
1251-
);
1248+
yield* desktopSettings.setUpdateChannel(nextChannel).pipe(
1249+
Effect.onError(() => Ref.set(releaseFloorRef, previousFloor)),
1250+
Effect.mapError(
1251+
(cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }),
1252+
),
1253+
);
12521254

12531255
const disabledReason = yield* resolveDisabledReason;
12541256
yield* setState(yield* baseUpdateState(nextChannel, disabledReason));

‎apps/mobile/src/features/threads/PendingUserInputCard.tsx‎

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ function QuestionText(props: {
8181
readonly threadId: ThreadId;
8282
readonly workspaceRoot: string | null;
8383
readonly setDirection: TextDirection | undefined;
84+
readonly preserveSoftBreaks?: boolean;
8485
}) {
8586
const styles = useMarkdownPreviewStyles();
8687
const markdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
@@ -113,6 +114,7 @@ function QuestionText(props: {
113114
Platform.OS === "android" ? undefined : props.setDirection === "rtl" ? "right" : "left"
114115
}
115116
onLinkPress={openLink}
117+
preserveSoftBreaks={props.preserveSoftBreaks}
116118
/>
117119
);
118120
}
@@ -279,43 +281,56 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
279281
const description =
280282
option.description !== option.label ? option.description : undefined;
281283
return (
282-
<Pressable
283-
key={optionValue}
284-
accessibilityRole={question.multiSelect ? "checkbox" : "radio"}
285-
accessibilityState={{ checked: selected, disabled: responseDisabled }}
286-
disabled={responseDisabled}
287-
className={cn(
288-
"min-h-12 w-full rounded-2xl border px-3.5 py-3",
289-
selected ? "border-primary bg-primary/10" : "border-border bg-input",
290-
)}
291-
onPress={() =>
292-
props.onSelectOption(
293-
props.pendingUserInput.requestId,
294-
question,
295-
optionValue,
296-
)
297-
}
298-
>
299-
<View className="min-w-0 flex-1 gap-0.5">
300-
<Text
301-
className={cn(
302-
"font-t3-bold text-sm",
303-
selected ? "text-foreground" : "text-foreground-secondary",
304-
)}
305-
style={proseDirectionStyle(option.label, setDirection)}
306-
>
307-
{option.label}
308-
</Text>
309-
{description ? (
284+
<View key={optionValue} className="gap-1.5">
285+
<Pressable
286+
accessibilityRole={question.multiSelect ? "checkbox" : "radio"}
287+
accessibilityState={{ checked: selected, disabled: responseDisabled }}
288+
disabled={responseDisabled}
289+
className={cn(
290+
"min-h-12 w-full rounded-2xl border px-3.5 py-3",
291+
selected ? "border-primary bg-primary/10" : "border-border bg-input",
292+
)}
293+
onPress={() =>
294+
props.onSelectOption(
295+
props.pendingUserInput.requestId,
296+
question,
297+
optionValue,
298+
)
299+
}
300+
>
301+
<View className="min-w-0 flex-1 gap-0.5">
310302
<Text
311-
className="font-sans text-sm leading-5 text-foreground-muted"
312-
style={proseDirectionStyle(description, setDirection)}
303+
className={cn(
304+
"font-t3-bold text-sm",
305+
selected ? "text-foreground" : "text-foreground-secondary",
306+
)}
307+
style={proseDirectionStyle(option.label, setDirection)}
313308
>
314-
{description}
309+
{option.label}
315310
</Text>
316-
) : null}
317-
</View>
318-
</Pressable>
311+
{description ? (
312+
<Text
313+
className="font-sans text-sm leading-5 text-foreground-muted"
314+
style={proseDirectionStyle(description, setDirection)}
315+
>
316+
{description}
317+
</Text>
318+
) : null}
319+
</View>
320+
</Pressable>
321+
{option.preview ? (
322+
<View className="px-3.5">
323+
<QuestionText
324+
markdown={option.preview}
325+
environmentId={props.environmentId}
326+
threadId={props.threadId}
327+
workspaceRoot={props.workspaceRoot}
328+
setDirection={setDirection}
329+
preserveSoftBreaks
330+
/>
331+
</View>
332+
) : null}
333+
</View>
319334
);
320335
})}
321336
</View>

‎apps/server/src/cli/invocation.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,37 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => {
109109
}),
110110
).toBe("sudo /opt/t3/t3 browser setup");
111111

112+
// A standalone executable started directly (systemd, no launcher) is still this install.
113+
expect(
114+
yield* rootCommand({
115+
node: "/opt/t3/t3",
116+
entry: "/opt/t3/t3",
117+
executable: true,
118+
path: yield* pathWithT3,
119+
}),
120+
).toBe("sudo /opt/t3/t3 browser setup");
121+
122+
// A global npm launcher runs the downloaded executable; PATH's `t3` is that launcher.
123+
const prefix = path.join(home, "npm");
124+
const npmLauncher = path.join(prefix, "lib/node_modules/t3/bin/t3.mjs");
125+
yield* fs.makeDirectory(path.dirname(npmLauncher), { recursive: true });
126+
yield* fs.makeDirectory(path.join(prefix, "bin"));
127+
yield* fs.writeFileString(npmLauncher, "", { mode: 0o755 });
128+
yield* fs.writeFileString(
129+
path.join(prefix, "lib/node_modules/t3/package.json"),
130+
`{"name":"t3","version":"${packageJson.version}"}`,
131+
);
132+
yield* fs.symlink(npmLauncher, path.join(prefix, "bin/t3"));
133+
expect(
134+
yield* rootCommand({
135+
node: "/home/theo/.t3/runtime/versions/1.0.0/t3",
136+
entry: "/home/theo/.t3/runtime/versions/1.0.0/t3",
137+
executable: true,
138+
path: path.join(prefix, "bin"),
139+
env: { T3CODE_NPM_LAUNCHER_PATH: npmLauncher },
140+
}),
141+
).toBe('sudo env "PATH=$PATH" t3 browser setup');
142+
112143
expect(
113144
yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }),
114145
).toBe('sudo env "PATH=$PATH" t3 browser setup');

‎apps/server/src/cli/invocation.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,17 @@ export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand
160160
const shim = (yield* HostProcess.Environment).T3CODE_CLI_PATH?.trim();
161161
const fs = yield* FileSystem.FileSystem;
162162
const real = (file: string) => fs.realPath(file).pipe(Effect.orElseSucceed(() => null));
163-
const shimReal = shim ? yield* real(shim) : null;
164-
if (shimReal === null || shimReal === (yield* real(onPath))) {
163+
// Windows `t3.cmd` shims are text files that never realpath to the executable they run.
164+
const own = shim
165+
? yield* real(shim)
166+
: platform !== "win32" && (yield* HostProcess.IsExecutable)
167+
? yield* resolveEntryPath.pipe(
168+
Effect.flatMap((entry) =>
169+
real(entry).pipe(Effect.map((resolved) => resolved ?? entry)),
170+
),
171+
)
172+
: null;
173+
if (own === null || own === (yield* real(onPath))) {
165174
return { command: withArgs(command), launcher: false };
166175
}
167176
}

0 commit comments

Comments
 (0)