Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
Expand Down Expand Up @@ -155,6 +156,66 @@ it.effect("discovers editors through the service API", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("memoizes editor discovery and refreshes after the cache window", () => {
let statCalls = 0;
const fileInfo = { type: "File" } as FileSystem.File.Info;
const launcherLayer = ExternalLauncher.layer.pipe(
Layer.provide(
Layer.mergeAll(
FileSystem.layerNoop({
stat: () =>
Effect.sync(() => {
statCalls += 1;
return fileInfo;
}),
}),
Path.layer,
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())),
),
),
),
);

return Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;

const first = yield* launcher.resolveAvailableEditors();
assert.equal(first.includes("vscode"), true);
const statCallsAfterFirstScan = statCalls;
assert.isAbove(statCallsAfterFirstScan, 0);

// Past the shared command-resolution cache TTL (30s) but within the
// discovery cache window: the memoized set is reused without any scan.
yield* TestClock.adjust("31 seconds");
const second = yield* launcher.resolveAvailableEditors();
assert.deepEqual([...second], [...first]);
assert.equal(statCalls, statCallsAfterFirstScan);

// Past the discovery cache window the next call rescans.
yield* TestClock.adjust("30 seconds");
yield* launcher.resolveAvailableEditors();
assert.isAbove(statCalls, statCallsAfterFirstScan);
}).pipe(
Effect.provide(
Layer.mergeAll(
launcherLayer,
Layer.succeed(HostProcessPlatform, "win32"),
ConfigProvider.layer(
ConfigProvider.fromEnv({
env: {
PATH: "C:\\t3-editor-discovery-cache-test",
PATHEXT: ".COM;.EXE;.BAT;.CMD",
},
}),
),
TestClock.layer(),
),
),
);
});

it.effect("rejects unknown editors through the service API", () =>
Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit
return yield* buildAvailableEditors(platform, env);
});

// Editor discovery walks PATH for every known editor and runs for every
// client connect (the server config embeds the available editors). Memoize
// the discovered set for a bounded window so repeat connects skip even the
// per-command cache lookups in @t3tools/shared/shell.
const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds";

/**
* ExternalLauncher - Service tag for browser/editor launch operations.
*/
Expand Down Expand Up @@ -443,8 +449,13 @@ export const make = Effect.gen(function* () {
Effect.provideService(Path.Path, path),
);

const cachedAvailableEditors = yield* Effect.cachedWithTTL(
provideCommandResolutionServices(resolveAvailableEditors()),
EDITOR_DISCOVERY_CACHE_TTL,
);

return ExternalLauncher.of({
resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()),
resolveAvailableEditors: () => cachedAvailableEditors,
launchBrowser: (target) =>
launchBrowser(target).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4732,12 +4732,21 @@ function ChatViewContent(props: ChatViewProps) {
isSendBusy ||
isConnecting ||
threadDetailLoading ||
activeEnvironmentUnavailable ||
sendInFlightRef.current
) {
notifyDirectAnnotationAttached();
return;
}
if (activeEnvironmentUnavailable) {
toastManager.add(
stackedThreadToast({
type: "warning",
title: "Not connected: message not sent",
description: "Reconnecting to the environment. Try again once it is connected.",
}),
);
return;
}
if (activePendingProgress) {
if (directAnnotation) {
notifyDirectAnnotationAttached();
Expand Down
112 changes: 89 additions & 23 deletions packages/client-runtime/src/connection/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ describe("EnvironmentSupervisor", () => {
const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt");
expect(firstAttempt).toBeDefined();

yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");
yield* awaitState(supervisor.state, (state) => state.phase === "connected");

const attempts = spans.filter((span) => span.name === "relay.connection.attempt");
Expand Down Expand Up @@ -358,7 +358,7 @@ describe("EnvironmentSupervisor", () => {
);
expect(yield* Ref.get(harness.prepareCount)).toBe(1);

for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) {
for (const [index, delay] of [3_000, 4_000, 8_000, 16_000, 16_000, 16_000].entries()) {
yield* TestClock.adjust(delay);
yield* eventuallyState(
supervisor.state,
Expand All @@ -384,7 +384,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");

const retrying = yield* awaitState(
supervisor.state,
Expand Down Expand Up @@ -489,7 +489,7 @@ describe("EnvironmentSupervisor", () => {
},
});

yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
expect(yield* Ref.get(harness.prepareCount)).toBe(2);
}).pipe(Effect.provide(TestClock.layer())),
Expand Down Expand Up @@ -526,7 +526,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");
yield* eventuallyState(
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 2,
Expand All @@ -539,7 +539,7 @@ describe("EnvironmentSupervisor", () => {
);
expect(yield* Ref.get(harness.prepareCount)).toBe(3);

yield* TestClock.adjust("999 millis");
yield* TestClock.adjust("2999 millis");
expect(yield* Ref.get(harness.prepareCount)).toBe(3);
yield* TestClock.adjust("1 milli");
yield* eventuallyState(
Expand Down Expand Up @@ -588,7 +588,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "blocked" && state.attempt === 2,
Expand Down Expand Up @@ -703,7 +703,7 @@ describe("EnvironmentSupervisor", () => {
);
expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true);

yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
Expand All @@ -728,7 +728,7 @@ describe("EnvironmentSupervisor", () => {
(state) => state.phase === "backoff" && state.attempt === 1,
);

yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
Expand All @@ -741,7 +741,7 @@ describe("EnvironmentSupervisor", () => {

expect(secondFailure.retryAt).not.toBeNull();

yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");
expect(yield* Ref.get(harness.sessionCount)).toBe(2);

yield* TestClock.adjust("1 second");
Expand All @@ -766,7 +766,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
Expand Down Expand Up @@ -805,7 +805,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2 && state.attempt === 2,
Expand Down Expand Up @@ -834,7 +834,7 @@ describe("EnvironmentSupervisor", () => {
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
yield* TestClock.adjust("1 second");
yield* TestClock.adjust("3 seconds");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connecting" && state.attempt === 2,
Expand Down Expand Up @@ -925,9 +925,14 @@ describe("EnvironmentSupervisor", () => {
}),
);

it.effect("reconnects when the foreground liveness probe fails", () =>
it.effect("reconnects immediately when the foreground liveness probe fails", () =>
Effect.gen(function* () {
const allowReconnect = yield* Deferred.make<void>();
const harness = yield* makeHarness({
prepare: (attempt) =>
attempt === 2
? Deferred.await(allowReconnect).pipe(Effect.as(PREPARED_CONNECTION))
: Effect.succeed(PREPARED_CONNECTION),
probe: (attempt) =>
attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void,
});
Expand All @@ -937,15 +942,77 @@ describe("EnvironmentSupervisor", () => {

yield* awaitState(supervisor.state, (state) => state.phase === "connected");
yield* harness.wake("application-active");
yield* awaitState(supervisor.state, (state) => state.phase === "backoff");
yield* TestClock.adjust("1 second");
const reconnecting = yield* awaitState(
supervisor.state,
(state) => state.phase === "connecting",
);
expect(reconnecting.attempt).toBe(1);
expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true);

// No TestClock advance: a failed wake probe skips the first backoff rung.
yield* Deferred.succeed(allowReconnect, undefined);
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
);

expect(yield* Ref.get(harness.sessionCount)).toBe(2);
expect(yield* Ref.get(harness.releaseCount)).toBe(1);
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("keeps normal backoff when a reconnect after a failed wake probe also fails", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
prepare: (attempt) =>
attempt === 2 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION),
probe: (attempt) =>
attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void,
});
const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
initiallyDesired: true,
}).pipe(Effect.provide(harness.dependencies));

yield* awaitState(supervisor.state, (state) => state.phase === "connected");
yield* harness.wake("application-active");
// The immediate follow-up attempt fails: only the first attempt after
// the wake probe skips the ladder, so this failure backs off normally.
yield* awaitState(
supervisor.state,
(state) => state.phase === "backoff" && state.attempt === 1,
);
yield* TestClock.adjust("2999 millis");
expect(yield* Ref.get(harness.prepareCount)).toBe(2);
yield* TestClock.adjust("1 milli");
yield* eventuallyState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
);

expect(yield* Ref.get(harness.prepareCount)).toBe(3);
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("uses the full tolerance window for a stalled desktop foreground probe", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({
probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void),
});
const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
initiallyDesired: true,
}).pipe(Effect.provide(harness.dependencies));

yield* awaitState(supervisor.state, (state) => state.phase === "connected");
yield* harness.wake("application-active");
yield* TestClock.adjust("14999 millis");
expect(yield* Ref.get(harness.sessionCount)).toBe(1);
yield* TestClock.adjust("1 milli");
yield* awaitState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
);

expect(yield* Ref.get(harness.sessionCount)).toBe(2);
expect(yield* Ref.get(harness.releaseCount)).toBe(1);
}).pipe(Effect.provide(TestClock.layer())),
);

Expand All @@ -961,15 +1028,14 @@ describe("EnvironmentSupervisor", () => {
yield* awaitState(supervisor.state, (state) => state.phase === "connected");
yield* harness.wake("application-active-probe");
yield* TestClock.adjust("3 seconds");
// The timed-out wake probe reconnects immediately without a backoff
// sleep: no further clock advance is needed.
yield* awaitState(
supervisor.state,
(state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout",
);
yield* TestClock.adjust("1 second");
yield* eventuallyState(
supervisor.state,
(state) => state.phase === "connected" && state.generation === 2,
(state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
);

expect(yield* Ref.get(harness.sessionCount)).toBe(2);
}).pipe(Effect.provide(TestClock.layer())),
);

Expand Down
Loading
Loading