| author | |
| committer | |
| log | 1dc8cbe6d14a7b4e131a2806f43bde8b60d6aaa7 |
| tree | 715cacb9edf4305b14cd2beac718e4fa58703f40 |
| parent | 6530de0339d2ca49957d0039133c49e3a08557f7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>4 files changed, 127 insertions(+), 0 deletions(-)
apps/server/src/observability/HeapSnapshot.test.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - tests fake a failed write at the native v8 boundary. | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as NodeFS from "node:fs"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | import * as NodeV8 from "node:v8"; | |
| 6 | import { assert, it } from "@effect/vitest"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import { vi } from "vite-plus/test"; | |
| 10 | ||
| 11 | import { writeHeapSnapshot } from "./HeapSnapshot.ts"; | |
| 12 | ||
| 13 | vi.mock("node:v8", async (importOriginal) => { | |
| 14 | const actual = await importOriginal<typeof NodeV8>(); | |
| 15 | return { ...actual, writeHeapSnapshot: vi.fn(actual.writeHeapSnapshot) }; | |
| 16 | }); | |
| 17 | ||
| 18 | it.layer(NodeServices.layer)("writeHeapSnapshot", (it) => { | |
| 19 | it.effect("removes the partial file when the write fails", () => | |
| 20 | Effect.gen(function* () { | |
| 21 | const fs = yield* FileSystem.FileSystem; | |
| 22 | const logsDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-heap-snapshot-test-" }); | |
| 23 | let partialPath: string | undefined; | |
| 24 | vi.mocked(NodeV8.writeHeapSnapshot).mockImplementationOnce((path) => { | |
| 25 | partialPath = path; | |
| 26 | if (path) NodeFS.writeFileSync(path, "partial"); | |
| 27 | throw new Error("ENOSPC: no space left on device"); | |
| 28 | }); | |
| 29 | ||
| 30 | yield* writeHeapSnapshot(logsDir); | |
| 31 | ||
| 32 | assert.strictEqual(NodePath.dirname(partialPath ?? ""), logsDir); | |
| 33 | assert.deepEqual(yield* fs.readDirectory(logsDir), []); | |
| 34 | }), | |
| 35 | ); | |
| 36 | }); |
apps/server/src/observability/HeapSnapshot.ts created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - v8.writeHeapSnapshot has no Effect equivalent. | |
| 2 | import * as NodePath from "node:path"; | |
| 3 | import * as NodeV8 from "node:v8"; | |
| 4 | ||
| 5 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 6 | import * as DateTime from "effect/DateTime"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | ||
| 11 | import * as ServerConfig from "../config.ts"; | |
| 12 | ||
| 13 | /** | |
| 14 | * Writes one V8 heap snapshot into `logsDir` and logs its path. A failed write | |
| 15 | * logs a warning and removes any partial file, because that file can hold | |
| 16 | * secrets and the failure is often a full disk. | |
| 17 | */ | |
| 18 | export const writeHeapSnapshot = Effect.fn("server.heapSnapshot", { root: true })( | |
| 19 | function* (logsDir: string) { | |
| 20 | const fs = yield* FileSystem.FileSystem; | |
| 21 | const timestamp = DateTime.formatIso(yield* DateTime.now).replaceAll(":", "-"); | |
| 22 | const path = NodePath.join(logsDir, `server-${process.pid}-${timestamp}.heapsnapshot`); | |
| 23 | yield* Effect.annotateCurrentSpan({ path }); | |
| 24 | yield* Effect.try(() => NodeV8.writeHeapSnapshot(path)).pipe( | |
| 25 | Effect.tapError(() => fs.remove(path, { force: true }).pipe(Effect.ignore)), | |
| 26 | ); | |
| 27 | yield* Effect.logInfo("Wrote heap snapshot.", { path }); | |
| 28 | }, | |
| 29 | Effect.catch((cause) => Effect.logWarning("Failed to write heap snapshot.", { cause })), | |
| 30 | ); | |
| 31 | ||
| 32 | /** | |
| 33 | * Writes a heap snapshot when the process gets SIGUSR2 (`kill -USR2 <pid>`), | |
| 34 | * so a maintainer can see what a long-running server holds. See "Heap | |
| 35 | * Snapshots" in docs/operations/observability.md. | |
| 36 | * | |
| 37 | * The write blocks the event loop, so two snapshots never overlap: a signal | |
| 38 | * sent during a write waits until it finishes. Windows has no SIGUSR2, so the | |
| 39 | * layer does nothing there. | |
| 40 | */ | |
| 41 | export const layer = Layer.effectDiscard( | |
| 42 | Effect.gen(function* () { | |
| 43 | if ((yield* HostProcessPlatform) === "win32") return; | |
| 44 | const { logsDir } = yield* ServerConfig.ServerConfig; | |
| 45 | const runFork = Effect.runForkWith(yield* Effect.context<FileSystem.FileSystem>()); | |
| 46 | const onSignal = () => void runFork(writeHeapSnapshot(logsDir)); | |
| 47 | yield* Effect.acquireRelease( | |
| 48 | Effect.sync(() => process.on("SIGUSR2", onSignal)), | |
| 49 | () => Effect.sync(() => process.off("SIGUSR2", onSignal)), | |
| 50 | ); | |
| 51 | }), | |
| 52 | ); |
apps/server/src/server.ts+2| ... | ... | @@ -119,6 +119,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe |
| 119 | 119 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 120 | 120 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; |
| 121 | 121 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; |
| 122 | import * as HeapSnapshot from "./observability/HeapSnapshot.ts"; | |
| 122 | 123 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 123 | 124 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| 124 | 125 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| ... | ... | @@ -957,6 +958,7 @@ const makeServerLayer = Layer.unwrap( |
| 957 | 958 | runtimeStateLayer.pipe(Layer.provide(launcherLayer)), |
| 958 | 959 | tailscaleServeLayer, |
| 959 | 960 | cloudDesiredLinkReconcileLayer, |
| 961 | HeapSnapshot.layer, | |
| 960 | 962 | ); |
| 961 | 963 | |
| 962 | 964 | return serverApplicationLayer.pipe( |
docs/operations/observability.md+37| ... | ... | @@ -618,3 +618,40 @@ Current high-value span and metric boundaries include: |
| 618 | 618 | - logs outside spans are not persisted in the trace file; SSH-managed launch stdout/stderr is still |
| 619 | 619 | captured in its launcher log |
| 620 | 620 | - metrics are not snapshotted locally |
| 621 | ||
| 622 | ## Heap Snapshots | |
| 623 | ||
| 624 | To see what a long-running server holds in memory, send it `SIGUSR2`. The server writes a V8 heap | |
| 625 | snapshot to its logs dir and logs the path. This works for desktop, `npx t3`, and service installs | |
| 626 | on macOS and Linux. Windows has no `SIGUSR2`. | |
| 627 | ||
| 628 | Send the signal to the server pid in `server-runtime.json`, which sits in the server's state dir | |
| 629 | next to the `logs` dir. For a dev server or a `--home-dir` launch, use that server's state dir from | |
| 630 | [Traces](#traces). Do not send it to the desktop app or the service launcher: a process without the | |
| 631 | handler exits on `SIGUSR2`. After a crash the file can keep a stale pid that now belongs to a | |
| 632 | different process, so check the pid first. | |
| 633 | ||
| 634 | ```bash | |
| 635 | pid="$(jq .pid "${T3CODE_HOME:-$HOME/.t3}/userdata/server-runtime.json")" | |
| 636 | ps -p "$pid" -o command= | |
| 637 | ``` | |
| 638 | ||
| 639 | If `ps` shows the T3 Code server, send the signal: | |
| 640 | ||
| 641 | ```bash | |
| 642 | kill -USR2 "$pid" | |
| 643 | ``` | |
| 644 | ||
| 645 | The file is `<logsDir>/server-<pid>-<timestamp>.heapsnapshot`, next to `server.trace.ndjson`. To | |
| 646 | open it, use the Memory tab in Chrome DevTools and select Load. | |
| 647 | ||
| 648 | Before you take one: | |
| 649 | ||
| 650 | - The server stops while it writes the file. For a large heap this can take a minute or more. | |
| 651 | Connected clients can reconnect during the pause, and an event loop monitor, if the server has | |
| 652 | one, records the pause as a stall. Send the signal once. A second signal sent during a write | |
| 653 | takes another snapshot after the first one finishes. | |
| 654 | - The write needs about as much free memory as the heap uses. On a machine that is already | |
| 655 | swapping, it can make the problem worse or crash the server. | |
| 656 | - The file contains everything in server memory, including tokens, secrets, and thread content. Do | |
| 657 | not share it publicly. Delete it when you are done, because storage cleanup does not remove it. |