Skip to content

Commit 00a2cc3

Browse files
Merge remote-tracking branch 'origin/main' into fix/issue-22966
# Conflicts: # packages/source-control-github/src/server/pullRequestReads.graphql.ts
2 parents c2bd7bd + 8d23699 commit 00a2cc3

165 files changed

Lines changed: 7092 additions & 781 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.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as Layer from "effect/Layer";
77
import * as FileSystem from "effect/FileSystem";
88
import * as Path from "effect/Path";
99
import * as HttpClient from "effect/http/HttpClient";
10+
import { ChildProcessSpawner } from "effect/process";
1011
import * as NetService from "@t3tools/shared/Net";
1112
import * as Fiber from "effect/Fiber";
1213
import * as Option from "effect/Option";
@@ -213,6 +214,7 @@ it.effect.each([false, true])(
213214
Effect.provide(
214215
Layer.mergeAll(
215216
Layer.mock(BrowserImport.BrowserImport, {}),
217+
Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}),
216218
Layer.mock(DesktopBackendConfiguration.DesktopBackendConfiguration, {}),
217219
Layer.mock(DesktopCliCommand.DesktopCliCommand, {}),
218220
Layer.mock(DesktopClientSettings.DesktopClientSettings, {}),

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

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ describe("DesktopBackendManager", () => {
421421
),
422422
);
423423

424-
it.effect("starts the configured backend and closes the scoped process on stop", () =>
424+
it.effect("stops the scoped process with output still pending", () =>
425425
Effect.scoped(
426426
Effect.gen(function* () {
427427
let startCount = 0;
@@ -458,6 +458,7 @@ describe("DesktopBackendManager", () => {
458458
yield* Scope.addFinalizer(scope, close);
459459

460460
return makeProcess({
461+
stdout: Stream.never,
461462
exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
462463
kill: () => close,
463464
});
@@ -721,14 +722,12 @@ describe("DesktopBackendManager", () => {
721722
),
722723
);
723724

724-
it.effect("the quit stop deadline waits for a backend that only exits at the force kill", () =>
725+
it.effect("the quit stop keeps waiting for a backend that outlasts its deadline", () =>
725726
Effect.scoped(
726727
Effect.gen(function* () {
727728
const spawned = yield* Deferred.make<void>();
728729
const teardownStarted = yield* Deferred.make<void>();
729730
const teardownFinished = yield* Deferred.make<void>();
730-
// The real spawner sends SIGTERM, waits the grace, then SIGKILLs. A backend that
731-
// ignores SIGTERM therefore finishes tearing down just after the grace.
732731
const instance = yield* makeTestInstance({
733732
spawnerLayer: Layer.succeed(
734733
ChildProcessSpawner.ChildProcessSpawner,
@@ -739,7 +738,7 @@ describe("DesktopBackendManager", () => {
739738
yield* Scope.addFinalizer(
740739
scope,
741740
Deferred.succeed(teardownStarted, undefined).pipe(
742-
Effect.andThen(Effect.sleep(Duration.seconds(6))),
741+
Effect.andThen(Effect.sleep(Duration.seconds(10))),
743742
Effect.andThen(Deferred.succeed(teardownFinished, undefined)),
744743
Effect.asVoid,
745744
),
@@ -764,11 +763,11 @@ describe("DesktopBackendManager", () => {
764763
.pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild);
765764

766765
yield* Deferred.await(teardownStarted);
767-
// Past the SIGTERM grace but before the force kill lands: quit must still be waiting.
768-
yield* TestClock.adjust(Duration.millis(5_500));
766+
// Past the quit deadline, quit must still wait for the backend to exit.
767+
yield* TestClock.adjust(Duration.seconds(9));
769768
assert.isFalse(yield* Deferred.isDone(quitDone));
770769

771-
yield* TestClock.adjust(Duration.millis(500));
770+
yield* TestClock.adjust(Duration.seconds(1));
772771
yield* Fiber.join(quitFiber);
773772
assert.isTrue(yield* Deferred.isDone(teardownFinished));
774773
}).pipe(Effect.provide(TestClock.layer())),

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ export interface DesktopBackendInstance {
240240
readonly id: BackendInstanceId;
241241
readonly label: Effect.Effect<string>;
242242
readonly start: Effect.Effect<void>;
243+
/** Timeout controls the warning; stop still waits for process teardown and exit. */
243244
readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>;
244245
readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>;
245246
readonly snapshot: Effect.Effect<DesktopBackendSnapshot>;
@@ -307,7 +308,7 @@ const closeRun = (
307308
run: ActiveBackendRun,
308309
parentScope: Scope.Scope,
309310
options?: { readonly timeout?: Duration.Duration },
310-
): Effect.Effect<boolean> => {
311+
) => {
311312
const waitForFiber = Option.match(run.fiber, {
312313
onNone: () => Effect.void,
313314
onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid),
@@ -319,12 +320,24 @@ const closeRun = (
319320
const timeout = options?.timeout;
320321

321322
if (!timeout) {
322-
return close.pipe(Effect.as(true));
323+
return close;
323324
}
324325

325-
return Effect.forkIn(close, parentScope).pipe(
326+
return Effect.forkIn(close, parentScope, { startImmediately: true }).pipe(
326327
Effect.flatMap((closeFiber) =>
327-
Fiber.await(closeFiber).pipe(Effect.timeoutOption(timeout), Effect.map(Option.isSome)),
328+
Fiber.await(closeFiber).pipe(
329+
Effect.timeoutOption(timeout),
330+
Effect.tap((completed) =>
331+
Option.isNone(completed)
332+
? logBackendProcessWarning("backend shutdown exceeded timeout; waiting for exit", {
333+
timeoutMs: Duration.toMillis(timeout),
334+
})
335+
: Effect.void,
336+
),
337+
// Parent-scope interruption still finishes release; do not cancel sibling stops.
338+
Effect.andThen(Fiber.await(closeFiber)),
339+
Effect.asVoid,
340+
),
328341
),
329342
);
330343
};
@@ -1099,10 +1112,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
10991112
onNone: () => Effect.void,
11001113
// The enclosing operation owns the span for this inline handler.
11011114
onSome: Effect.fnUntraced(function* (run) {
1102-
const closed = yield* closeRun(run, parentScope, options);
1103-
if (!closed) {
1104-
return;
1105-
}
1115+
yield* closeRun(run, parentScope, options);
11061116
const cleanup = yield* mutex.withPermits(1)(
11071117
SubscriptionRef.modify(
11081118
state,

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

Lines changed: 159 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { assert, describe, it } from "@effect/vitest";
2+
import * as Deferred from "effect/Deferred";
3+
import * as Duration from "effect/Duration";
24
import * as Effect from "effect/Effect";
5+
import * as Fiber from "effect/Fiber";
6+
import * as Queue from "effect/Queue";
7+
import * as TestClock from "effect/testing/TestClock";
38
import * as FileSystem from "effect/FileSystem";
49
import * as Layer from "effect/Layer";
510
import * as Option from "effect/Option";
611
import * as Ref from "effect/Ref";
12+
import * as Sink from "effect/Sink";
713
import * as Stream from "effect/Stream";
8-
import { HttpClient } from "effect/http";
14+
import { HttpClient, HttpClientResponse } from "effect/http";
915
import { ChildProcessSpawner } from "effect/process";
1016

1117
import * as DesktopObservability from "../app/DesktopObservability.ts";
@@ -21,18 +27,105 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
2127
import * as DesktopBackendPool from "./DesktopBackendPool.ts";
2228
import type { DesktopBackendStartConfig } from "./DesktopBackendManager.ts";
2329

24-
function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.DesktopBackendPool> {
30+
// Models the spawner's release for a backend that ignores SIGTERM, without OS signals.
31+
const makeFakeBackendProcess = Effect.fn("test.makeFakeBackendProcess")(function* () {
32+
const started = yield* Deferred.make<void>();
33+
const exited = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
34+
const signals = yield* Queue.unbounded<"SIGTERM" | "SIGKILL">();
35+
let running = false;
36+
const killWait = Duration.seconds(1);
37+
const exit = Effect.sync(() => {
38+
running = false;
39+
}).pipe(Effect.andThen(Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0))), Effect.asVoid);
40+
const layer = Layer.succeed(
41+
ChildProcessSpawner.ChildProcessSpawner,
42+
ChildProcessSpawner.make((command) =>
43+
Effect.gen(function* () {
44+
if (command._tag !== "StandardCommand") {
45+
return yield* Effect.die("Expected a standard backend command");
46+
}
47+
const terminateGrace = Duration.fromInputUnsafe(command.options.forceKillAfter ?? 0);
48+
running = true;
49+
yield* Effect.addFinalizer(() =>
50+
Effect.gen(function* () {
51+
yield* Queue.offer(signals, "SIGTERM");
52+
yield* Effect.sleep(terminateGrace);
53+
yield* Queue.offer(signals, "SIGKILL");
54+
yield* Effect.sleep(killWait);
55+
yield* exit;
56+
}),
57+
);
58+
yield* Deferred.succeed(started, undefined);
59+
return ChildProcessSpawner.makeHandle({
60+
pid: ChildProcessSpawner.ProcessId(123),
61+
stdout: Stream.empty,
62+
stderr: Stream.empty,
63+
all: Stream.empty,
64+
stdin: Sink.drain,
65+
getInputFd: () => Sink.drain,
66+
getOutputFd: () => Stream.empty,
67+
exitCode: Deferred.await(exited),
68+
isRunning: Effect.sync(() => running),
69+
kill: () => exit,
70+
unref: Effect.succeed(Effect.void),
71+
});
72+
}),
73+
),
74+
);
75+
return {
76+
layer,
77+
started: Deferred.await(started),
78+
signals,
79+
isRunning: Effect.sync(() => running),
80+
killWait,
81+
};
82+
});
83+
84+
const runningConfig = {
85+
executablePath: "/electron",
86+
args: [],
87+
entryPath: "/server/bin.mjs",
88+
cwd: "/server",
89+
env: {},
90+
bootstrap: {
91+
mode: "desktop",
92+
noBrowser: true,
93+
port: 3774,
94+
t3Home: "/tmp/t3",
95+
host: "127.0.0.1",
96+
desktopBootstrapToken: "token",
97+
tailscaleServeEnabled: false,
98+
tailscaleServePort: 443,
99+
},
100+
bootstrapDelivery: "fd3",
101+
extendEnv: true,
102+
httpBaseUrl: new URL("http://127.0.0.1:3774"),
103+
captureOutput: false,
104+
preflightFailure: Option.none(),
105+
} satisfies DesktopBackendStartConfig;
106+
107+
function layerPool(
108+
labelRef: Ref.Ref<string>,
109+
backend?: Effect.Success<ReturnType<typeof makeFakeBackendProcess>>,
110+
): Layer.Layer<DesktopBackendPool.DesktopBackendPool> {
25111
return DesktopBackendPool.layer.pipe(
26112
Layer.provideMerge(
27113
Layer.mergeAll(
28-
FileSystem.layerNoop({}),
29-
Layer.succeed(
30-
ChildProcessSpawner.ChildProcessSpawner,
31-
ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")),
32-
),
114+
FileSystem.layerNoop({ exists: () => Effect.succeed(true) }),
115+
backend?.layer ??
116+
Layer.succeed(
117+
ChildProcessSpawner.ChildProcessSpawner,
118+
ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")),
119+
),
33120
Layer.succeed(
34121
HttpClient.HttpClient,
35-
HttpClient.make(() => Effect.die("unexpected HTTP request")),
122+
HttpClient.make((request) =>
123+
backend === undefined
124+
? Effect.die("unexpected HTTP request")
125+
: Effect.succeed(
126+
HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })),
127+
),
128+
),
36129
),
37130
Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {
38131
forInstance: () =>
@@ -90,6 +183,62 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
90183
}
91184

92185
describe("DesktopBackendPool", () => {
186+
it.effect("unregister during pool quit does not interrupt sibling stops", () =>
187+
Effect.scoped(
188+
Effect.gen(function* () {
189+
const backend = yield* makeFakeBackendProcess();
190+
yield* Effect.gen(function* () {
191+
const pool = yield* DesktopBackendPool.DesktopBackendPool;
192+
const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu");
193+
const ready = yield* Deferred.make<void>();
194+
const siblingStarted = yield* Deferred.make<void>();
195+
const finishSibling = yield* Deferred.make<void>();
196+
let siblingCompleted = false;
197+
const instance = yield* pool.register({
198+
id,
199+
label: Effect.succeed("WSL (Ubuntu)"),
200+
configResolve: Effect.succeed(runningConfig),
201+
onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid),
202+
});
203+
yield* instance.start;
204+
yield* backend.started;
205+
yield* Deferred.await(ready);
206+
const quitFiber = yield* Effect.all(
207+
[
208+
instance.stop({ timeout: Duration.seconds(5) }),
209+
Deferred.succeed(siblingStarted, undefined).pipe(
210+
Effect.andThen(Deferred.await(finishSibling)),
211+
Effect.andThen(
212+
Effect.sync(() => {
213+
siblingCompleted = true;
214+
}),
215+
),
216+
),
217+
],
218+
{ concurrency: "unbounded" },
219+
).pipe(Effect.forkChild);
220+
yield* Deferred.await(siblingStarted);
221+
assert.equal(yield* Queue.take(backend.signals), "SIGTERM");
222+
const unregisterFiber = yield* pool.unregister(id).pipe(Effect.forkChild);
223+
yield* TestClock.adjust(Duration.seconds(5));
224+
assert.equal(yield* Queue.take(backend.signals), "SIGKILL");
225+
assert.isUndefined(quitFiber.pollUnsafe());
226+
assert.isUndefined(unregisterFiber.pollUnsafe());
227+
assert.isTrue(yield* backend.isRunning);
228+
yield* TestClock.adjust(backend.killWait);
229+
yield* Fiber.join(unregisterFiber);
230+
yield* Deferred.succeed(finishSibling, undefined);
231+
yield* Fiber.join(quitFiber);
232+
233+
assert.isTrue(siblingCompleted);
234+
assert.isFalse(yield* backend.isRunning);
235+
assert.isTrue(Option.isNone((yield* instance.snapshot).activePid));
236+
assert.isTrue(Option.isNone(yield* pool.get(id)));
237+
}).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), backend)));
238+
}),
239+
),
240+
);
241+
93242
it.effect("unregister stops the registered instance", () =>
94243
Effect.scoped(
95244
Effect.gen(function* () {
@@ -99,27 +248,9 @@ describe("DesktopBackendPool", () => {
99248
id,
100249
label: Effect.succeed("WSL (Ubuntu)"),
101250
configResolve: Effect.succeed({
102-
executablePath: "/electron",
103-
args: [],
104-
entryPath: "/server/bin.mjs",
105-
cwd: "/server",
106-
env: {},
107-
bootstrap: {
108-
mode: "desktop",
109-
noBrowser: true,
110-
port: 3774,
111-
t3Home: "/tmp/t3",
112-
host: "127.0.0.1",
113-
desktopBootstrapToken: "token",
114-
tailscaleServeEnabled: false,
115-
tailscaleServePort: 443,
116-
},
117-
bootstrapDelivery: "fd3",
118-
extendEnv: true,
119-
httpBaseUrl: new URL("http://127.0.0.1:3774"),
120-
captureOutput: false,
251+
...runningConfig,
121252
preflightFailure: Option.some({ reason: "WSL starting", fatal: false }),
122-
} satisfies DesktopBackendStartConfig),
253+
}),
123254
});
124255
yield* instance.start;
125256
assert.equal((yield* instance.snapshot).restartScheduled, true);

‎apps/desktop/src/electron/ElectronMenu.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
7575

7676
const normalizedItem: ContextMenuItem = {
7777
id: sourceItem.id,
78-
label: sourceItem.label,
78+
label:
79+
typeof sourceItem.detail === "string" && sourceItem.detail.length > 0
80+
? `${sourceItem.label} (${sourceItem.detail})`
81+
: sourceItem.label,
7982
destructive: sourceItem.destructive === true,
8083
disabled: sourceItem.disabled === true,
8184
...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),

0 commit comments

Comments
 (0)