Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
bb7783e
feat(search): Cmd+K finds the right thread first, with indexed messag…
t3dotgg Oct 24, 2026
9a85aac
refactor(source-control): forges parse their own change request URLs …
juliusmarminge Oct 24, 2026
3d4c753
refactor(source-control): forges recognise their own remotes and repo…
juliusmarminge Oct 24, 2026
7368bd3
refactor(source-control): drivers build their own services and own ho…
juliusmarminge Oct 24, 2026
39f6e2b
refactor(source-control): git and process behaviour comes from forge …
juliusmarminge Oct 24, 2026
233f6ea
refactor(source-control): pull request behaviour comes from forge cap…
juliusmarminge Oct 24, 2026
a6009fa
refactor(clients): pull request surfaces ask the forge instead of ass…
juliusmarminge Oct 24, 2026
326e08b
refactor(clients): forge settings, routing and icons come from defini…
juliusmarminge Oct 24, 2026
218e5c0
fix(clients): change requests use their host's own term (#23999)
juliusmarminge Oct 24, 2026
4d87110
test(source-control): forge behaviour is tested in its own package (#…
juliusmarminge Oct 24, 2026
814bd67
refactor(source-control): forge definitions live in shared/, not clie…
juliusmarminge Oct 24, 2026
6e79211
refactor(source-control): forges handle their own listing and SSH hos…
juliusmarminge Oct 24, 2026
d0e621e
fix(mobile): the question card uses the whole screen while the keyboa…
shivamhwp Oct 24, 2026
a46f80e
fix(web): screen readers hear which background thread finished, even …
shivamhwp Oct 24, 2026
7f7daad
fix(web): screen readers say which thread a shortcut switched to (#23…
shivamhwp Oct 24, 2026
d53873b
fix(mobile): Back from a sub-agent opened from its delegator returns …
shivamhwp Oct 24, 2026
79014e6
fix(server): migrate-dev-db no longer trips over webhook tables (#24404)
juliusmarminge Oct 24, 2026
71df573
fix(clients): GitCafe links show GitCafe's mark on mobile (#24405)
juliusmarminge Oct 24, 2026
0345e85
feat: compactions T3 Code starts read as one working row, and stay qu…
juliusmarminge Oct 24, 2026
ea41701
fix(source-control): GitCafe checks show each check's newest run (#24…
juliusmarminge Oct 24, 2026
8176b58
fix(html): honor preview light and dark mode (#24521)
Bil0000 Oct 24, 2026
bf5c483
chore(mobile): move Enriched Markdown to upstream's block renderers (…
juliusmarminge Oct 24, 2026
be1d066
fix(provider-cursor): finished Cursor turns no longer end in Provider…
shivamhwp Oct 24, 2026
3afb9b3
fix(server): server preview browser rejects the compromised eMudhra i…
juliusmarminge Oct 24, 2026
eea9895
chore(review): Macroscope skips generated code and recorded fixtures …
juliusmarminge Oct 24, 2026
909e4c9
fix(server): align delegated review follow-up guidance (#24560)
juliusmarminge Oct 24, 2026
3cc31ed
fix(server): websocket tickets name the route they open (#24554)
juliusmarminge Oct 24, 2026
76da264
perf(mobile): highlight code with native @pierre/highlights instead o…
juliusmarminge Oct 24, 2026
7fd2e09
fix(desktop): support Linux distro protocol launchers (#24591)
juliusmarminge Oct 24, 2026
c4fa462
fix(provider-acp): force-kill terminal commands that ignore SIGTERM (…
juliusmarminge Oct 24, 2026
843f4e3
fix(grok): invoke leading picked skills through native commands (#24605)
juliusmarminge Oct 24, 2026
553203c
fix(server): long agent turns no longer load whole when a thread open…
t3dotgg Oct 24, 2026
8bcef34
fix(server): analytics stop counting reconnects, retries, and dev ser…
t3dotgg Oct 24, 2026
5a969c1
fix(acp): idle agent errors fail their turns (#24615)
juliusmarminge Oct 25, 2026
0840bb2
fix(provider-acp): Grok monitors show as monitors in background work …
t3dotgg Oct 25, 2026
249b6ae
feat(server): devices on the same network reach background services o…
t3dotgg Oct 25, 2026
7769d70
fix(codex): bound pending requests after app-server stdout closes (#2…
juliusmarminge Oct 25, 2026
ce8d644
fix(cursor): discover built-in skills in the composer (#24595)
juliusmarminge Oct 25, 2026
97548db
fix(server): retry provider event writes after brief SQLite locks (#2…
juliusmarminge Oct 25, 2026
3eb8366
fix(server): settle inactive threads with persisted open PR links (#2…
juliusmarminge Oct 25, 2026
5848bc2
feat(mobile): Settings is a workspace tab, and tabs never open on an …
juliusmarminge Oct 25, 2026
5cf4995
feat(mobile): the folded Duo opens search from a search tab below the…
juliusmarminge Oct 25, 2026
95ffbc9
revert(mobile): take the folded Duo search tab back out for now (#24626)
juliusmarminge Oct 25, 2026
27805ef
fix(web): preview images outside the workspace (#24627)
juliusmarminge Oct 25, 2026
91ea0e1
fix(threads): Working holds only background work the agent waits on (…
t3dotgg Oct 25, 2026
fa1347d
fix(web): switched threads and Scroll to end land on the real end (#2…
t3dotgg Oct 25, 2026
14b828e
fix(web): attach DNG originals as generic files (#24625)
juliusmarminge Oct 25, 2026
cd2b681
fix(server): surface failed runtime request answer delivery (#24616)
juliusmarminge Oct 25, 2026
b8be6da
fix(preview): report navigation timeout accurately (#24356)
Ethereal49 Oct 25, 2026
945fd1d
fix(web): give PR row metadata room below the title (#24553)
juliusmarminge Oct 25, 2026
5ed8b54
fix(mobile): center floating status actions (#24526)
PixPMusic Oct 25, 2026
453bf01
fix(mobile): the iPhone new-chat button sits beside the tab bar on iO…
wojas Oct 25, 2026
6548c00
fix(web): signed-out T3 Connect shows a Sign in button (#24636)
t3dotgg Oct 25, 2026
62eb35b
perf(mobile): stop menu fault logging and whole-list re-renders on Ho…
juliusmarminge Oct 25, 2026
074d9e4
fix(mobile): preserve syntax and navigation in large diffs (#24276)
juliusmarminge Oct 25, 2026
67c3afb
perf(shell): cold connects skip idle subagent threads, a third of the…
t3dotgg Oct 25, 2026
fb76594
feat: share a thread with a guest who watches live and sends messages…
t3dotgg Oct 25, 2026
2887855
fix(antigravity): say when sign-in cannot reach Google (#24652)
t3dotgg Oct 25, 2026
eabcfa2
fix: canonicalize git.cafe pull request links (#22634)
maria-rcks Oct 25, 2026
edb2b2f
feat(mobile): manage labels, edit PRs and comments, react, and refres…
juliusmarminge Oct 25, 2026
d228f1f
perf(web): highlight code with @pierre/highlights instead of Shiki (#…
maria-rcks Oct 25, 2026
e19fe08
perf(server): thread opens and live events send each message body onc…
t3dotgg Oct 25, 2026
8f26ed7
refactor: halve code comments and cap comment blocks at four lines (#…
maria-rcks Oct 25, 2026
68d5b7a
chore: fit three comments to the four-line cap so lint passes on main…
t3dotgg Oct 25, 2026
24c80f3
fix(claude): ENABLE_TOOL_SEARCH=auto no longer costs 10x cache writes…
t3dotgg Oct 25, 2026
3199267
Merge remote-tracking branch 'upstream/main' into yordis/chore-sync-u…
yordis Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
36 changes: 7 additions & 29 deletions .github/scripts/ci-changes.cjs
Original file line number Diff line number Diff line change
@@ -1,37 +1,24 @@
// Decides which CI jobs a change needs. Prints `key=value` lines for
// $GITHUB_OUTPUT; a job runs unless its key is "false".
//
// node ci-changes.cjs --paths <file> gate by the listed paths
// node ci-changes.cjs --paths <file> --full run every Linux job, gate only the macOS lint
// node ci-changes.cjs changed paths unknown: run everything
//
// scripts/check-changed.ts requires the same decisions so local runs match CI.
//
// Every changed path maps to workspace packages, which then pull in their
// dependents from package.json. A path this file does not recognize runs
// everything, so a new root file or directory can only cost minutes.
// Unknown paths run every job; recognized packages pull in their dependents.
// scripts/check-changed.ts shares these decisions so local checks match CI.
const fs = require("node:fs");
const path = require("node:path");

const root = path.resolve(__dirname, "../..");

// Never read by a build, typecheck, or test. Lint still formats them.
const INERT = [
/^docs\//,
/^\.(agents|cursor|vscode|devcontainer|macroscope|repos)\//,
/^[^/]+\.md$/,
/^LICENSE$/,
];

// Run everything: the root vite.config.ts loads the first as setup for every
// package's tests, and the vendored mobile tarballs are installed dependencies.
// Shared test setup and vendored mobile dependencies affect every package.
const RUNS_EVERYTHING = [
/^packages\/shared\/src\/(HostProcess|testing\/longTempDir)\.ts$/,
/^apps\/mobile\/deps\//,
];

// Paths outside a package, or files inside one that other packages read
// through relative imports rather than a package.json dependency.
// Includes cross-package reads absent from package.json dependencies.
const EXTRA_OWNERS = [
// Vite configs and tsconfigs of these apps import scripts/lib directly.
[/^scripts\/lib\//, ["@t3tools/desktop", "@t3tools/mobile", "@t3tools/web", "t3"]],
Expand All @@ -43,9 +30,7 @@ const EXTRA_OWNERS = [
];
const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//;

// Tests that read another package's files without declaring it. The scripts
// tests read app manifests, assets, and sources (cursor-sdk-packaging,
// third-party-licenses, brand-assets, build-desktop-artifact).
// These tests read other packages without declaring dependencies.
const TEST_ONLY_DEPS = {
"@t3tools/provider-pi": ["@t3tools/client-runtime"],
"@t3tools/scripts": [
Expand All @@ -56,17 +41,12 @@ const TEST_ONLY_DEPS = {
"@t3tools/provider-cursor",
],
};
// Declared dependencies that only feed a build step: the server bundles the
// built web app (apps/server/vite.config.ts) but never imports it.
// The server bundles the web build without importing it.
const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] };

// Sources scripts/mobile-native-static-check.ts lints, plus the tool and rule
// configuration that decides how it lints them, plus the root package.json
// that defines the lint:mobile command.
const MOBILE_NATIVE =
/^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//;

// Jobs that test every package except these, which have jobs of their own.
const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]);

function readWorkspace() {
Expand Down Expand Up @@ -108,8 +88,7 @@ function readWorkspace() {
return packages;
}

// Returns the packages a path touches, [] for an inert path, or null when the
// path is not understood and everything must run.
// [] means inert; null means unknown and requires every job.
function ownersOf(file, packages) {
if (INERT.some((pattern) => pattern.test(file))) return [];
// A manifest can change dependencies or versions other packages read.
Expand Down Expand Up @@ -148,7 +127,6 @@ function decide(files, packages, full) {
if (everything) {
affected = all;
} else {
// Add dependents until nothing changes.
let grew = true;
while (grew) {
grew = false;
Expand Down
13 changes: 3 additions & 10 deletions .github/scripts/mirror-play-apk.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
// Publishes closed-testing betas, or downloads the internal APK for a normal
// nightly release. Play generates and signs the APK from the
// uploaded bundle with the app signing key, so it carries the same signature
// as the Play Store install. Run by .github/workflows/mobile-android-apk.yml.
// Play signs generated APKs with the app signing key, matching Play Store installs.
const { execFileSync } = require("node:child_process");
const crypto = require("node:crypto");
const fs = require("node:fs");
Expand Down Expand Up @@ -50,10 +47,7 @@ async function play(token, method, url) {
return response;
}

// Highest version code the track serves, with its release name. Play names a
// release after the bundle's versionName unless the uploader sets one. This
// endpoint reads without opening an edit; an edit would invalidate any open
// EAS Submit edit made with the same service account.
// Opening an edit would invalidate any EAS Submit edit using the same service account.
async function newestTrackBuild(token, appUrl, trackName) {
const { releases = [] } = await (
await play(token, "GET", `${appUrl}/tracks/${trackName}/releases`)
Expand Down Expand Up @@ -82,8 +76,7 @@ function readRelease(tag) {
}
}

// Download to a temporary directory so a failed request cannot leave a partial
// APK among the release assets.
// Stage downloads so failed requests cannot leave partial release assets.
async function downloadApk({ token, appUrl, build, apkName }) {
const generated = await (
await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`)
Expand Down
7 changes: 2 additions & 5 deletions .github/scripts/stage-preview-bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
"server/dist/client/index.html",
"desktop/dist-electron/main.cjs",
}
# The current bundle is about 32 MiB compressed. Bound extraction on the
# trusted runner even when the PR replaces the uploader entirely.
# Bound trusted-runner extraction even when a PR replaces the uploader.
MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024
MAX_ENTRIES = 50_000
Expand Down Expand Up @@ -58,9 +57,7 @@ def stage_bundle(archive: Path, destination: Path):
files.add(name)
if not REQUIRED_FILES <= files:
raise ValueError("Preview bundle is missing required entry points")
# Validate all names before writing anything. This is a fresh directory
# outside the checkout; neither pre-existing links nor trusted files
# can be followed or overwritten. ZIP permissions are never restored.
# Validate before writing into a fresh directory so existing links/files cannot be followed or overwritten.
destination.mkdir(parents=True, exist_ok=False)
for entry in entries:
target = destination / entry.filename
Expand Down
3 changes: 1 addition & 2 deletions .github/scripts/thread-transfer-report.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,7 @@ async function resolve({ github, context, core }) {

const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id);
const sourceArtifact = findResultArtifact(sourceArtifacts);
// CI requires the artifact whenever the server tests run, so a passing run
// without one skipped them because the PR cannot affect the server.
// Successful server tests require this artifact; its absence means CI skipped them.
if (!sourceArtifact && source.conclusion === "success") {
core.info("CI skipped the server tests for this change; nothing to report.");
core.setOutput("publish", "false");
Expand Down
24 changes: 24 additions & 0 deletions .macroscope/ignore.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,27 @@ ENV/**
# (see scripts/lib/reference-repos.ts). Nothing imports from them; findings
# there belong upstream.
.repos/**

# Generated code. Review its source (the script, GraphQL document, schema
# definition, or theme) instead; review agents can still read these on demand.
**/*.generated.ts
**/*.graphql.ts
apps/server/schema/expo.graphql
packages/source-control-github/schema/github.graphql
apps/mobile/generated-uniwind-*
apps/mobile/clerk-theme.json
apps/server/src/testUtils/shardWeights.json
infra/relay/migrations/**/snapshot.json

# Provider transcripts recorded from real CLI runs for the orchestration replay
# tests. Each scenario's input.ts and *_output.ts assertions stay reviewed.
**/*.ndjson

# Ghostty build output vendored from upstream.
apps/mobile/modules/t3-terminal/Vendor/**
native/libghostty-vt/include/**

# @pierre/highlights compiled to C by wasm2c, plus the wasm2c runtime and simde
# it needs. Regenerated by apps/mobile/scripts/generate-highlights.mts; review
# that script and cpp/t3_highlights.c instead.
apps/mobile/modules/t3-highlights/Vendor/**
7 changes: 2 additions & 5 deletions .pnpmfile.cjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
// Windows WSL dev mode runs the server under Linux from the same node_modules
// as the Windows client, so Windows installs also need Linux x64 glibc native
// modules. Other platforms install only their own binaries; setting this
// everywhere added ~2.2 GB of foreign binaries to every macOS install.
// Release scripts stage their own installs with explicit architectures.
// Windows installs need Linux x64 glibc binaries because WSL shares node_modules.
// Keep other platforms native; release scripts set their own architectures.
module.exports = {
hooks: {
// `typescript` is @tsc-rs/typescript (the catalog entry). Its npm version (0.8.x) is outside
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface NodeServeHandler {
export interface NodeServeEntryOptions {
/**
* JS expression resolving the on-disk client-assets directory
* (typically `fileURLToPath(new URL("../client/", import.meta.url))`).
* (typically `fileURLToPath(new URL("../shared/", import.meta.url))`).
* Omit to skip static-file serving (Next.js handles its own assets).
*/
readonly clientDirExpression?: string | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ describe("relativeClientDirExpression", () => {
"/project/dist/server/serve-node.mjs",
"/project/dist/client",
),
).toBe(`fileURLToPath(new URL("../client/", import.meta.url))`);
).toBe(`fileURLToPath(new URL("../shared/", import.meta.url))`);
});
});

describe("makeNodeServeEntrySource", () => {
it("listens on PORT, answers /health, serves static files, then the fetch handler", () => {
const source = makeNodeServeEntrySource({
clientDirExpression: `fileURLToPath(new URL("../client/", import.meta.url))`,
clientDirExpression: `fileURLToPath(new URL("../shared/", import.meta.url))`,
handler: {
kind: "fetch",
imports: `import { handler } from "./index.mjs";`,
Expand Down Expand Up @@ -131,7 +131,7 @@ describe("makeNodeServeEntrySource", () => {

it("does not map GET / onto public/index.html (SSR home stays on the handler)", () => {
const source = makeNodeServeEntrySource({
clientDirExpression: `fileURLToPath(new URL("../client/", import.meta.url))`,
clientDirExpression: `fileURLToPath(new URL("../shared/", import.meta.url))`,
handler: {
kind: "node",
imports: `import { handler } from "./index.mjs";`,
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ Most code changes do not need an internal documentation change. Agents can read

- `docs/internals/` is for architectural decisions and their reasons, constraints that span components, and implementation traps that are hard to discover from the source. Before adding a paragraph, ask what a maintainer would get wrong without it. If reading the relevant code answers the question, leave it out.
- Do not document every feature, enumerate fields or methods, narrate control flow, maintain file catalogs, or append PR summaries. Types, tests, and code already record the implementation. The glossary defines shared vocabulary; it is not a feature index.
- Keep a local implementation explanation in a nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it.
- A local reason the code can't show goes in a short nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it.
- When a documented decision or constraint changes, rewrite or remove the affected text. Do not append another account of the new behavior. A new internal page needs a distinct, durable reason to exist.
- `docs/user/` helps users accomplish tasks. Give each major feature a concise section explaining what it does, how to start, and anything unintuitive. A settings path is useful; descriptions of visible buttons, icons, layouts, animations, or every UI state are not. Before adding text, ask what task or decision it helps the user with.
- Keep user docs in the shipped product's voice, without implementation details or contributor tooling. Update the relevant feature section when how to use it changes. A UI tweak does not need a documentation entry, and a new control does not need its own page.
Expand Down Expand Up @@ -194,7 +194,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/
Named `Effect.fn` creates a span; the unnamed form does not. Preserve existing span names and
options, and pass combinators as additional function-builder arguments.
- Inferred types over annotations. `any` is the enemy.
- Comments say why: a constraint, an external quirk, or an invariant the code can't show. Don't restate names or signatures, narrate steps, or record history. `t3code/no-low-value-comments` catches the mechanical cases.
- Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases.
- Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays.
- If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it.

Expand Down
14 changes: 3 additions & 11 deletions apps/desktop/scripts/electron-launcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -391,9 +391,7 @@ function buildMacLauncher(electronBinaryPath) {
JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata)
) {
if (isDevelopment) {
// The launcher also handles protocol activations outside the dev runner,
// so refresh its fallback environment on every launch. Never let a value
// captured by an older parent app override the live dev-runner environment.
// Protocol activations bypass the dev runner, so refresh the fallback environment on every launch.
writeDevelopmentEnvironmentScript();
if (writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath)) {
signMacLauncherBundle(targetAppBundlePath);
Expand All @@ -404,10 +402,7 @@ function buildMacLauncher(electronBinaryPath) {
}

NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true });
// verbatimSymlinks keeps the framework's relative symlinks intact
// (e.g. Resources -> Versions/Current/Resources). Without it cpSync
// rewrites them to absolute paths into node_modules, which escape the
// bundle and crash sandboxed helper processes (icudtl.dat not found).
// Without verbatimSymlinks, cpSync makes framework symlinks point outside the bundle and crashes helpers.
NodeFS.cpSync(sourceAppBundlePath, targetAppBundlePath, {
recursive: true,
verbatimSymlinks: true,
Expand All @@ -419,10 +414,7 @@ function buildMacLauncher(electronBinaryPath) {
);
patchHelperBundleInfoPlists(targetAppBundlePath);
if (isDevelopment) {
// Launching the node_modules copy makes macOS associate the process (and Dock label) with
// Electron.app even though this bundle's Info.plist has the T3 Code name. Its conventional
// executable name also keeps Electron's default-app runtime in development mode instead of
// making app.isPackaged report true.
// Electron.app's executable name keeps default-app mode active without making app.isPackaged true.
writeDevelopmentEnvironmentScript();
writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath);
}
Expand Down
6 changes: 1 addition & 5 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,11 +339,7 @@ const scopedProgram = Effect.scoped(
const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;

yield* Effect.addFinalizer(() =>
// Stop every backend in the pool, not just the primary. The
// electronApp.quit() path can race ahead of the layer-scope
// cascade, so leaving the WSL instance for its parent scope
// finalizer means it gets hard-killed by the OS instead of
// receiving SIGTERM + grace.
// Quit can outrun layer finalizers; stop pooled backends here so they receive SIGTERM before OS teardown.
stopAllPoolInstances().pipe(
Effect.ensuring(rendererHistory.shutdown),
Effect.ensuring(shutdown.markComplete),
Expand Down
12 changes: 4 additions & 8 deletions apps/desktop/src/app/DesktopAppActivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,7 @@ export async function startDesktopAppControlServer(input: {
server.listen(address);
});

// Closing a Unix socket server unlinks the path it was bound to, even when
// another app's socket lives there now. Bind a staging path and move it onto
// the address instead, so a later close only unlinks the staging path, which
// is already gone.
// Closing unlinks the original bind path, even if another app replaced it. Bind a staging path first.

const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => {
const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`);
Expand All @@ -213,7 +210,6 @@ export async function startDesktopAppControlServer(input: {
let server: NodeNet.Server;
let inode: number | null = null;
if (input.directory === null) {
// Named pipes close with the app that owns them, so no other app can remove this one.
server = await listen(input.address);
} else {
await prepareUnixDirectory({ directory: input.directory, userId: input.userId });
Expand All @@ -222,10 +218,10 @@ export async function startDesktopAppControlServer(input: {

let closed = false;
const reclaimOnce = async () => {
// Never replace a socket that exists, so two apps cannot trade the path back and forth.
// Replacing an existing socket would let two apps trade the path indefinitely.
if (closed || input.directory === null || (await inodeAt(input.address)) !== null) return;
if (await prepareUnixDirectory({ directory: input.directory, userId: input.userId })) {
// A watch follows the directory's inode, so a recreated directory needs a new one.
// Directory watches follow inodes, so recreate the watch after directory replacement.
watchDirectory(input.directory);
}
const next = await bindUnix(input.directory, "claim-free").catch(
Expand Down Expand Up @@ -269,7 +265,7 @@ export async function startDesktopAppControlServer(input: {
close: async () => {
if (closed) return;
closed = true;
// A running reclaim can replace the watcher, so close the watcher after it.
// Reclaim can replace the watcher; close it afterward.
await pendingReclaim;
watcher?.close();
for (const socket of sockets) socket.destroy();
Expand Down
Loading
Loading