authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-04 16:19:36-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log981cf405612932afa8a0b98b8c98474ef7e1015d
tree77f358d2b39ba5f0104d2f6d8bf9e7cfaf9a0388
parent689dda59e997337103c76ed93af0574dcc554d26

Improve session restart handling and server logging

- add structured lifecycle logs for Codex thread open/resume and provider session restarts - introduce persistent server file logging layer and tighten WebSocket server startup/finalizer ordering - preserve env-based boolean overrides in dev runner and stop forcing WS event logging in dev - enrich provider session listing from persisted bindings and fix Claude rate-limit/task-id event mapping - update orchestration and dev-runner tests for runtime-mode restart and env-flag behavior

12 files changed, 386 insertions(+), 74 deletions(-)

apps/server/scripts/logger-scope-repro.ts created+66
......@@ -0,0 +1,66 @@
1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import path from "node:path";
4
5import { Effect, FileSystem, Layer, Logger, ServiceMap } from "effect";
6
7import { makeEventNdjsonLogger } from "../src/provider/Layers/EventNdjsonLogger.ts";
8
9class LogDir extends ServiceMap.Service<LogDir, string>()("t3/scripts/logger-scope-repro/LogDir") {}
10
11const main = Effect.gen(function* () {
12 const logdir = yield* LogDir;
13 const providerLogPath = path.join(logdir, "provider");
14
15 yield* Effect.logInfo(`providerLogPath=${providerLogPath}`);
16
17 const providerLogger = yield* makeEventNdjsonLogger(providerLogPath, {
18 stream: "native",
19 batchWindowMs: 10,
20 });
21
22 yield* Effect.logInfo("before provider write");
23
24 if (providerLogger) {
25 yield* providerLogger.write(
26 {
27 kind: "probe",
28 message: "provider-only event",
29 },
30 "thread-123" as never,
31 );
32 }
33
34 yield* Effect.logInfo("after provider write");
35 yield* Effect.sleep("50 millis");
36
37 if (providerLogger) {
38 yield* providerLogger.close();
39 }
40 yield* Effect.logInfo("after provider close");
41
42 const fs = yield* FileSystem.FileSystem;
43 const logContents = yield* fs
44 .readDirectory(logdir, { recursive: true })
45 .pipe(
46 Effect.flatMap((entries) =>
47 Effect.all(entries.map((entry) => fs.readFileString(path.join(logdir, entry)))),
48 ),
49 );
50 yield* Effect.logInfo(`logContents=${logContents}`);
51});
52
53Effect.gen(function* () {
54 const fs = yield* FileSystem.FileSystem;
55 const logdir = path.join(process.cwd(), "logtest");
56 yield* fs.makeDirectory(logdir);
57
58 const fileLogger = yield* Logger.formatSimple.pipe(
59 Logger.toFile(path.join(logdir, "global.log")),
60 );
61 const dualLogger = Logger.layer([fileLogger, Logger.consolePretty()]);
62
63 const mainLayer = Layer.mergeAll(dualLogger, Layer.succeed(LogDir, logdir));
64
65 yield* main.pipe(Effect.provide(mainLayer));
66}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain);
apps/server/src/codexAppServerManager.ts+42
......@@ -18,6 +18,7 @@ import {
1818 type ProviderSessionStartInput,
1919 type ProviderTurnStartResult,
2020} from "@t3tools/contracts";
21import { Effect } from "effect";
2122
2223type PendingRequestKey = string;
2324
......@@ -265,6 +266,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
265266 experimentalRawEvents: false,
266267 };
267268 const resumeThreadId = readResumeThreadId(input);
269 this.emitLifecycleEvent(
270 context,
271 "session/threadOpenRequested",
272 resumeThreadId
273 ? `Attempting to resume thread ${resumeThreadId}.`
274 : "Starting a new Codex thread.",
275 );
276 await Effect.logInfo("codex app-server opening thread", {
277 sessionId,
278 requestedRuntimeMode: input.runtimeMode,
279 requestedModel: normalizedModel ?? null,
280 requestedCwd: resolvedCwd,
281 resumeThreadId: resumeThreadId ?? null,
282 }).pipe(Effect.runPromise);
268283
269284 let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start";
270285 let threadOpenResponse: unknown;
......@@ -277,6 +292,18 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
277292 });
278293 } catch (error) {
279294 if (!isRecoverableThreadResumeError(error)) {
295 this.emitErrorEvent(
296 context,
297 "session/threadResumeFailed",
298 error instanceof Error ? error.message : "Codex thread resume failed.",
299 );
300 await Effect.logWarning("codex app-server thread resume failed", {
301 sessionId,
302 requestedRuntimeMode: input.runtimeMode,
303 resumeThreadId,
304 recoverable: false,
305 cause: error instanceof Error ? error.message : String(error),
306 }).pipe(Effect.runPromise);
280307 throw error;
281308 }
282309
......@@ -286,6 +313,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
286313 "session/threadResumeFallback",
287314 `Could not resume thread ${resumeThreadId}; started a new thread instead.`,
288315 );
316 await Effect.logWarning("codex app-server thread resume fell back to fresh start", {
317 sessionId,
318 requestedRuntimeMode: input.runtimeMode,
319 resumeThreadId,
320 recoverable: true,
321 cause: error instanceof Error ? error.message : String(error),
322 }).pipe(Effect.runPromise);
289323 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
290324 }
291325 } else {
......@@ -307,6 +341,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
307341 threadId,
308342 resumeCursor: { threadId },
309343 });
344 this.emitLifecycleEvent(context, "session/threadOpenResolved", `Codex ${threadOpenMethod} resolved.`);
345 await Effect.logInfo("codex app-server thread open resolved", {
346 sessionId,
347 threadOpenMethod,
348 requestedResumeThreadId: resumeThreadId ?? null,
349 resolvedThreadId: threadId,
350 requestedRuntimeMode: input.runtimeMode,
351 }).pipe(Effect.runPromise);
310352 this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`);
311353 return { ...context.session };
312354 } catch (error) {
apps/server/src/main.ts+21-26
......@@ -24,6 +24,7 @@ import * as SqlitePersistence from "./persistence/Layers/Sqlite";
2424import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
2525import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";
2626import { Server } from "./wsServer";
27import { ServerLoggerLive } from "./serverLogger";
2728
2829export class StartupError extends Data.TaggedError("StartupError")<{
2930 readonly message: string;
......@@ -120,6 +121,9 @@ const CliEnvConfig = Config.all({
120121 ),
121122});
122123
124const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
125 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
126
123127const ServerConfigLive = (input: CliInput) =>
124128 Layer.effect(
125129 ServerConfig,
......@@ -151,25 +155,16 @@ const ServerConfigLive = (input: CliInput) =>
151155 Option.getOrUndefined(input.stateDir) ?? env.stateDir,
152156 );
153157 const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl);
154 const noBrowser = Option.match(input.noBrowser, {
155 // effect/cli boolean flags parse to `false` when absent; in that case
156 // we still want env/mode fallbacks to apply.
157 onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")),
158 onNone: () => env.noBrowser ?? mode === "desktop",
159 });
158 const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop");
160159 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;
161 const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, {
162 // effect/cli boolean flags parse to `false` when absent; in that case
163 // we still want env/mode fallbacks to apply.
164 onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")),
165 onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web",
166 });
167 const logWebSocketEvents = Option.match(input.logWebSocketEvents, {
168 // effect/cli boolean flags parse to `false` when absent; in that case
169 // we still want env/dev fallbacks to apply.
170 onSome: (value) => (value ? true : (env.logWebSocketEvents ?? Boolean(devUrl))),
171 onNone: () => env.logWebSocketEvents ?? Boolean(devUrl),
172 });
160 const autoBootstrapProjectFromCwd = resolveBooleanFlag(
161 input.autoBootstrapProjectFromCwd,
162 env.autoBootstrapProjectFromCwd ?? mode === "web",
163 );
164 const logWebSocketEvents = resolveBooleanFlag(
165 input.logWebSocketEvents,
166 env.logWebSocketEvents ?? Boolean(devUrl),
167 );
173168 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
174169 const { join } = yield* Path.Path;
175170 const keybindingsConfigPath = join(stateDir, "keybindings.json");
......@@ -178,7 +173,7 @@ const ServerConfigLive = (input: CliInput) =>
178173 env.host ??
179174 (mode === "desktop" ? "127.0.0.1" : undefined);
180175
181 return {
176 const config: ServerConfigShape = {
182177 mode,
183178 port,
184179 cwd: cliConfig.cwd,
......@@ -192,6 +187,8 @@ const ServerConfigLive = (input: CliInput) =>
192187 autoBootstrapProjectFromCwd,
193188 logWebSocketEvents,
194189 } satisfies ServerConfigShape;
190
191 return config;
195192 }),
196193 );
197194
......@@ -201,6 +198,7 @@ const LayerLive = (input: CliInput) =>
201198 Layer.provideMerge(makeServerProviderLayer()),
202199 Layer.provideMerge(ProviderHealthLive),
203200 Layer.provideMerge(SqlitePersistence.layerConfig),
201 Layer.provideMerge(ServerLoggerLive),
204202 Layer.provideMerge(ServerConfigLive(input)),
205203 );
206204
......@@ -235,14 +233,11 @@ const makeServerProgram = (input: CliInput) =>
235233 config.host && !isWildcardHost(config.host)
236234 ? `http://${formatHostForUrl(config.host)}:${config.port}`
237235 : localUrl;
236 const { authToken, devUrl, ...safeConfig } = config;
238237 yield* Effect.logInfo("T3 Code running", {
239 url: bindUrl,
240 localUrl,
241 bindHost: config.host ?? "default",
242 cwd: config.cwd,
243 mode: config.mode,
244 stateDir: config.stateDir,
245 authEnabled: Boolean(config.authToken),
238 ...safeConfig,
239 devUrl: devUrl?.toString(),
240 authEnabled: Boolean(authToken),
246241 });
247242
248243 if (!config.noBrowser) {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+30
......@@ -509,6 +509,16 @@ describe("ProviderCommandReactor", () => {
509509 const harness = await createHarness();
510510 const now = new Date().toISOString();
511511
512 await Effect.runPromise(
513 harness.engine.dispatch({
514 type: "thread.runtime-mode.set",
515 commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access"),
516 threadId: ThreadId.makeUnsafe("thread-1"),
517 runtimeMode: "full-access",
518 createdAt: now,
519 }),
520 );
521
512522 await Effect.runPromise(
513523 harness.engine.dispatch({
514524 type: "thread.turn.start",
......@@ -538,6 +548,11 @@ describe("ProviderCommandReactor", () => {
538548 }),
539549 );
540550
551 await waitFor(async () => {
552 const readModel = await Effect.runPromise(harness.engine.getReadModel());
553 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
554 return thread?.runtimeMode === "approval-required";
555 });
541556 await waitFor(() => harness.startSession.mock.calls.length === 2);
542557 await waitFor(() => harness.stopSession.mock.calls.length === 1);
543558
......@@ -636,6 +651,16 @@ describe("ProviderCommandReactor", () => {
636651 const harness = await createHarness();
637652 const now = new Date().toISOString();
638653
654 await Effect.runPromise(
655 harness.engine.dispatch({
656 type: "thread.runtime-mode.set",
657 commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access-2"),
658 threadId: ThreadId.makeUnsafe("thread-1"),
659 runtimeMode: "full-access",
660 createdAt: now,
661 }),
662 );
663
639664 await Effect.runPromise(
640665 harness.engine.dispatch({
641666 type: "thread.turn.start",
......@@ -669,6 +694,11 @@ describe("ProviderCommandReactor", () => {
669694 }),
670695 );
671696
697 await waitFor(async () => {
698 const readModel = await Effect.runPromise(harness.engine.getReadModel());
699 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
700 return thread?.runtimeMode === "approval-required";
701 });
672702 await waitFor(() => harness.startSession.mock.calls.length === 2);
673703 await Effect.runPromise(Effect.sleep("30 millis"));
674704
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+21
......@@ -283,10 +283,31 @@ const make = Effect.gen(function* () {
283283 providerChanged || shouldRestartForModelChange
284284 ? undefined
285285 : (activeSession?.resumeCursor ?? undefined);
286 yield* Effect.logInfo("provider command reactor restarting provider session", {
287 threadId,
288 existingSessionId,
289 currentProvider,
290 desiredProvider: options?.provider ?? currentProvider,
291 currentRuntimeMode: thread.session?.runtimeMode,
292 desiredRuntimeMode: thread.runtimeMode,
293 runtimeModeChanged,
294 providerChanged,
295 modelChanged,
296 shouldRestartForModelChange,
297 hasResumeCursor: resumeCursor !== undefined,
298 });
286299 const restartedSession = yield* startProviderSession({
287300 ...(resumeCursor !== undefined ? { resumeCursor } : {}),
288301 ...(options?.provider !== undefined ? { provider: options.provider } : {}),
289302 });
303 yield* Effect.logInfo("provider command reactor restarted provider session", {
304 threadId,
305 previousSessionId: existingSessionId,
306 restartedSessionId: restartedSession.sessionId,
307 provider: restartedSession.provider,
308 providerThreadId: restartedSession.threadId,
309 runtimeMode: restartedSession.runtimeMode,
310 });
290311 yield* bindSessionToThread(restartedSession);
291312 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(
292313 Effect.catchCause((cause) =>
apps/server/src/orchestration/decider.projectScripts.test.ts+6-4
......@@ -251,10 +251,12 @@ describe("decider project scripts", () => {
251251 if (singleResult === null) {
252252 throw new Error("Expected a single runtime-mode-set event.");
253253 }
254 expect(singleResult.type).toBe("thread.runtime-mode-set");
255 expect(singleResult.payload).toMatchObject({
256 threadId: ThreadId.makeUnsafe("thread-1"),
257 runtimeMode: "approval-required",
254 expect(singleResult).toMatchObject({
255 type: "thread.runtime-mode-set",
256 payload: {
257 threadId: ThreadId.makeUnsafe("thread-1"),
258 runtimeMode: "approval-required",
259 },
258260 });
259261 });
260262});
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+6-5
......@@ -34,6 +34,7 @@ import {
3434 RuntimeItemId,
3535 RuntimeRequestId,
3636 RuntimeSessionId,
37 RuntimeTaskId,
3738 ThreadId,
3839 TurnId,
3940} from "@t3tools/contracts";
......@@ -1130,7 +1131,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
11301131 ...base,
11311132 type: "task.started",
11321133 payload: {
1133 taskId: message.task_id,
1134 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
11341135 description: message.description,
11351136 ...(message.task_type ? { taskType: message.task_type } : {}),
11361137 },
......@@ -1141,7 +1142,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
11411142 ...base,
11421143 type: "task.progress",
11431144 payload: {
1144 taskId: message.task_id,
1145 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
11451146 description: message.description,
11461147 ...(message.usage ? { usage: message.usage } : {}),
11471148 ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}),
......@@ -1153,7 +1154,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
11531154 ...base,
11541155 type: "task.completed",
11551156 payload: {
1156 taskId: message.task_id,
1157 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
11571158 status: message.status,
11581159 ...(message.summary ? { summary: message.summary } : {}),
11591160 ...(message.usage ? { usage: message.usage } : {}),
......@@ -1259,7 +1260,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
12591260 return;
12601261 }
12611262
1262 if (message.type === "rate_limit") {
1263 if (message.type === "rate_limit_event") {
12631264 yield* offerRuntimeEvent({
12641265 ...base,
12651266 type: "account.rate-limits.updated",
......@@ -1295,7 +1296,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
12951296 case "tool_progress":
12961297 case "tool_use_summary":
12971298 case "auth_status":
1298 case "rate_limit":
1299 case "rate_limit_event":
12991300 yield* handleSdkTelemetryMessage(context, message);
13001301 return;
13011302 default:
apps/server/src/provider/Layers/ProviderService.ts+44-3
......@@ -539,9 +539,50 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
539539 });
540540
541541 const listSessions: ProviderServiceShape["listSessions"] = () =>
542 Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe(
543 Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)),
544 );
542 Effect.gen(function* () {
543 const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions());
544 const activeSessions = sessionsByProvider.flatMap((sessions) => sessions);
545 const persistedBindings = yield* directory
546 .listSessionIds()
547 .pipe(
548 Effect.flatMap((sessionIds) =>
549 Effect.forEach(
550 sessionIds,
551 (sessionId) =>
552 directory.getBinding(sessionId).pipe(
553 Effect.orElseSucceed(() => Option.none<ProviderSessionBinding>()),
554 ),
555 { concurrency: "unbounded" },
556 ),
557 ),
558 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderSessionBinding>>),
559 );
560 const bindingsBySessionId = new Map<ProviderSessionId, ProviderSessionBinding>();
561 for (const bindingOption of persistedBindings) {
562 const binding = Option.getOrUndefined(bindingOption);
563 if (binding) {
564 bindingsBySessionId.set(binding.sessionId, binding);
565 }
566 }
567
568 return activeSessions.map((session) => {
569 const binding = bindingsBySessionId.get(session.sessionId);
570 if (!binding) {
571 return session;
572 }
573
574 return {
575 ...session,
576 ...(session.threadId === undefined && binding.providerThreadId
577 ? { threadId: binding.providerThreadId }
578 : {}),
579 ...(session.resumeCursor === undefined && binding.resumeCursor !== undefined
580 ? { resumeCursor: binding.resumeCursor }
581 : {}),
582 ...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}),
583 };
584 });
585 });
545586
546587 const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) =>
547588 registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities));
apps/server/src/serverLogger.ts created+27
......@@ -0,0 +1,27 @@
1import fs from "node:fs";
2import path from "node:path";
3
4import { Effect, Logger, Option } from "effect";
5import * as Layer from "effect/Layer";
6
7import { ServerConfig } from "./config";
8
9export const ServerLoggerLive = Effect.gen(function* () {
10 const config = yield* Effect.serviceOption(ServerConfig);
11 if (Option.isNone(config)) {
12 return Logger.layer([Logger.defaultLogger]);
13 }
14
15 const logDir = path.join(config.value.stateDir, "logs");
16 const logPath = path.join(logDir, "server.log");
17
18 yield* Effect.sync(() => {
19 fs.mkdirSync(logDir, { recursive: true });
20 });
21
22 const fileLogger = Logger.formatSimple.pipe(Logger.toFile(logPath));
23
24 return Logger.layer([Logger.defaultLogger, fileLogger], {
25 mergeWithExisting: false,
26 });
27}).pipe(Layer.unwrap);
apps/server/src/wsServer.ts+32-30
......@@ -613,6 +613,33 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
613613 );
614614 }
615615
616 const runPromise = yield* Effect.map(Effect.services<never>(), Effect.runPromiseWith);
617 yield* Effect.addFinalizer(() =>
618 Effect.catch(liveProviderService.stopAll(), (cause) =>
619 Effect.logWarning("failed to stop provider service", { cause }),
620 ),
621 );
622
623 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(
624 (event) => void Effect.runPromise(onTerminalEvent(event)),
625 );
626 yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents()));
627
628 yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe(
629 Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })),
630 );
631
632 yield* Effect.addFinalizer(() =>
633 Effect.all([
634 closeAllClients,
635 closeWebSocketServer.pipe(
636 Effect.catch((error) =>
637 Effect.logWarning("failed to close web socket server", { cause: error }),
638 ),
639 ),
640 ]),
641 );
642
616643 const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) {
617644 switch (request.body._tag) {
618645 case ORCHESTRATION_WS_METHODS.getSnapshot:
......@@ -800,6 +827,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
800827 id: request.value.id,
801828 result: result.value,
802829 });
830
803831 ws.send(response);
804832 });
805833
......@@ -828,7 +856,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
828856 });
829857
830858 wss.on("connection", (ws) => {
831 void Effect.runPromise(Ref.update(clients, (clients) => clients.add(ws)));
859 void runPromise(Ref.update(clients, (clients) => clients.add(ws)));
832860
833861 const segments = cwd.split(/[/\\]/).filter(Boolean);
834862 const projectName = segments[segments.length - 1] ?? "project";
......@@ -847,7 +875,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
847875 ws.send(JSON.stringify(welcome));
848876
849877 ws.on("message", (raw) => {
850 void Effect.runPromise(
878 void runPromise(
851879 handleMessage(ws, raw).pipe(
852880 Effect.catch((error) => Effect.logError("Error handling message", error)),
853881 ),
......@@ -855,7 +883,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
855883 });
856884
857885 ws.on("close", () => {
858 void Effect.runPromise(
886 void runPromise(
859887 Ref.update(clients, (clients) => {
860888 clients.delete(ws);
861889 return clients;
......@@ -864,7 +892,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
864892 });
865893
866894 ws.on("error", () => {
867 void Effect.runPromise(
895 void runPromise(
868896 Ref.update(clients, (clients) => {
869897 clients.delete(ws);
870898 return clients;
......@@ -873,32 +901,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
873901 });
874902 });
875903
876 yield* Effect.addFinalizer(() =>
877 Effect.catch(liveProviderService.stopAll(), (cause) =>
878 Effect.logWarning("failed to stop provider service", { cause }),
879 ),
880 );
881
882 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(
883 (event) => void Effect.runPromise(onTerminalEvent(event)),
884 );
885 yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents()));
886
887 yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe(
888 Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })),
889 );
890
891 yield* Effect.addFinalizer(() =>
892 Effect.all([
893 closeAllClients,
894 closeWebSocketServer.pipe(
895 Effect.catch((error) =>
896 Effect.logWarning("failed to close web socket server", { cause: error }),
897 ),
898 ),
899 ]),
900 );
901
902904 return httpServer;
903905});
904906
scripts/dev-runner.test.ts+45
......@@ -97,6 +97,51 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
9797 assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/");
9898 }),
9999 );
100
101 it.effect("does not force websocket logging on in dev mode when unset", () =>
102 Effect.gen(function* () {
103 const env = yield* createDevRunnerEnv({
104 mode: "dev",
105 baseEnv: {
106 T3CODE_LOG_WS_EVENTS: "keep-me-out",
107 },
108 serverOffset: 0,
109 webOffset: 0,
110 stateDir: undefined,
111 authToken: undefined,
112 noBrowser: undefined,
113 autoBootstrapProjectFromCwd: undefined,
114 logWebSocketEvents: undefined,
115 host: undefined,
116 port: undefined,
117 devUrl: undefined,
118 });
119
120 assert.equal(env.T3CODE_MODE, "web");
121 assert.equal(env.T3CODE_LOG_WS_EVENTS, undefined);
122 }),
123 );
124
125 it.effect("forwards explicit websocket logging false without coercing it away", () =>
126 Effect.gen(function* () {
127 const env = yield* createDevRunnerEnv({
128 mode: "dev",
129 baseEnv: {},
130 serverOffset: 0,
131 webOffset: 0,
132 stateDir: undefined,
133 authToken: undefined,
134 noBrowser: undefined,
135 autoBootstrapProjectFromCwd: undefined,
136 logWebSocketEvents: false,
137 host: undefined,
138 port: undefined,
139 devUrl: undefined,
140 });
141
142 assert.equal(env.T3CODE_LOG_WS_EVENTS, "0");
143 }),
144 );
100145 });
101146
102147 describe("findFirstAvailableOffset", () => {
scripts/dev-runner.ts+46-6
......@@ -189,9 +189,6 @@ export function createDevRunnerEnv({
189189
190190 if (mode === "dev") {
191191 output.T3CODE_MODE = "web";
192 if (logWebSocketEvents === undefined) {
193 output.T3CODE_LOG_WS_EVENTS = "1";
194 }
195192 delete output.T3CODE_DESKTOP_WS_URL;
196193 }
197194
......@@ -350,6 +347,35 @@ interface DevRunnerCliInput {
350347 readonly turboArgs: ReadonlyArray<string>;
351348}
352349
350const readOptionalBooleanEnv = (name: string): boolean | undefined => {
351 const value = process.env[name];
352 if (value === undefined) {
353 return undefined;
354 }
355 if (value === "1" || value.toLowerCase() === "true") {
356 return true;
357 }
358 if (value === "0" || value.toLowerCase() === "false") {
359 return false;
360 }
361 return undefined;
362};
363
364const resolveOptionalBooleanOverride = (
365 explicitValue: boolean | undefined,
366 envValue: boolean | undefined,
367): boolean | undefined => {
368 if (explicitValue === true) {
369 return true;
370 }
371
372 if (explicitValue === false) {
373 return envValue;
374 }
375
376 return envValue;
377};
378
353379export function runDevRunnerWithInput(input: DevRunnerCliInput) {
354380 return Effect.gen(function* () {
355381 const { portOffset, devInstance } = yield* OffsetConfig.asEffect().pipe(
......@@ -371,6 +397,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
371397 }),
372398 });
373399
400 const envOverrides = {
401 noBrowser: readOptionalBooleanEnv("T3CODE_NO_BROWSER"),
402 autoBootstrapProjectFromCwd: readOptionalBooleanEnv(
403 "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD",
404 ),
405 logWebSocketEvents: readOptionalBooleanEnv("T3CODE_LOG_WS_EVENTS"),
406 };
407
374408 const { serverOffset, webOffset } = yield* resolveModePortOffsets({
375409 mode: input.mode,
376410 startOffset: offset,
......@@ -385,9 +419,15 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
385419 webOffset,
386420 stateDir: input.stateDir,
387421 authToken: input.authToken,
388 noBrowser: input.noBrowser,
389 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,
390 logWebSocketEvents: input.logWebSocketEvents,
422 noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser),
423 autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride(
424 input.autoBootstrapProjectFromCwd,
425 envOverrides.autoBootstrapProjectFromCwd,
426 ),
427 logWebSocketEvents: resolveOptionalBooleanOverride(
428 input.logWebSocketEvents,
429 envOverrides.logWebSocketEvents,
430 ),
391431 host: input.host,
392432 port: input.port,
393433 devUrl: input.devUrl,