Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/defer-syntax-worker-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"hunkdiff": patch
---

Keep `hunk --version`, `--help`, `daemon serve`, and `hunk session *` off the diff-engine startup
path again, and release the syntax worker when the review app exits instead of at startup.
12 changes: 4 additions & 8 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { prepareStartupPlan } from "./app/startup";
import { sanitizeTerminalText } from "./lib/terminalText";
import { serveSessionBrokerDaemon } from "./session/broker/brokerServer";
import { runSessionCommand } from "./session/agent/commands";
import { disposeHighlightWorker } from "./ui/diff/worker";

async function main() {
const startupPlan = await prepareStartupPlan();
Expand Down Expand Up @@ -102,13 +101,10 @@ async function main() {

// OpenTUI stays behind the interactive plan so headless commands never materialize its embedded
// native library. The highlighting client starts the compiled worker only when an opted-in,
// eligible large diff needs it, so normal sessions do not pay its startup cost.
try {
const { runInteractiveApp } = await import("./ui/runInteractiveApp");
await runInteractiveApp(startupPlan);
} finally {
disposeHighlightWorker();
}
// eligible large diff needs it, so normal sessions do not pay its startup cost. The interactive
// app owns that worker's disposal: this call returns once the app is mounted, not once it exits.
const { runInteractiveApp } = await import("./ui/runInteractiveApp");
await runInteractiveApp(startupPlan);
}

await main().catch((error) => {
Expand Down
5 changes: 5 additions & 0 deletions src/ui/runInteractiveApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
} from "../session/types";
import { SessionBrokerClient } from "../session/broker/brokerClient";
import { AppHost } from "./AppHost";
import { disposeHighlightWorker } from "./diff/worker";

export interface InteractiveAppInput {
bootstrap: AppBootstrap;
Expand Down Expand Up @@ -92,6 +93,10 @@ export async function runInteractiveApp({
jobControlInterruptSupport.dispose();
jobControlSuspendSupport.dispose();
hostClient.stop();
// Release the syntax worker here rather than from the executable entrypoint: this function
// returns once the app is mounted, so an entrypoint-side dispose would fire before the first
// large diff ever asked for the worker.
disposeHighlightWorker();
shutdownSession({ root, renderer: appRenderer });
}

Expand Down
113 changes: 113 additions & 0 deletions test/cli/startup-graph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Startup test is not colocated

This regression test lives under test/cli/ instead of beside the source it protects, contrary to the repository's test-colocation requirement and making it easier to overlook during future startup lifecycle changes.

Context Used: testing.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/cli/startup-graph.test.ts
Line: 1

Comment:
**Startup test is not colocated**

This regression test lives under `test/cli/` instead of beside the source it protects, contrary to the repository's test-colocation requirement and making it easier to overlook during future startup lifecycle changes.

**Context Used:** testing.mdc Cursor rule ([source](https://github.com/modem-dev/modem/blob/main/.cursor/rules/testing.mdc))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this where it is — test/cli/ is the location this repo's own convention calls for.

CLAUDE.md states both rules:

- Colocate unit tests with the code they cover (`src/core/foo.ts` + `src/core/foo.test.ts`, ...).
- Use repo-level `test/` directories by intent:
  - `test/cli/` for black-box CLI contract coverage.

This is black-box coverage of a CLI contract: it treats src/main.tsx as an opaque entrypoint and asserts a property of the whole command surface — that --version, --help, daemon serve, and session * answer without loading the diff engine. It isn't a unit test of any one module, so there is no single source file to sit beside. It lands next to test/cli/entrypoint.test.ts, which covers the same entrypoint the same way.

Worth flagging that the cited testing.mdc is from modem-dev/modem, a different repository — hunk has no .cursor/ directory, and its own CLAUDE.md is the governing convention here.

On the underlying concern — that this is easy to overlook during future startup lifecycle changes — the test fails loudly with the offending import chain rather than a generic assertion, e.g.:

@pierre/diffs via src/main.tsx -> src/ui/diff/worker/index.ts
  -> src/ui/diff/worker/highlightCompact.ts
  -> src/ui/diff/worker/highlightHast.ts -> @pierre/diffs

Generated by Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and I apologize for the noise. The placement is correct per hunk's own CLAUDE.md, and I should not have cited testing.mdc from modem-dev/modem — that rule doesn't govern this repository.

The failure message with the full import chain is a good touch; it makes the guard self-documenting when it trips.

Shall I remember a rule: "In modem-dev/hunk reviews, do not cite .cursor/ rules from modem-dev/modem — hunk's CLAUDE.md is the governing convention, and test/cli/ is the correct location for black-box CLI contract tests"?

import { Transpiler } from "bun";
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";

/**
* Guards the startup cost of commands that never build a changeset.
*
* `hunk --version`, `--help`, `daemon serve`, the markup commands, and `hunk session *` answer
* without a diff engine, renderer, or VCS backend. Those subsystems are reached through dynamic
* `import()` from the interactive plan, so only eager `import` statements can pull them into the
* entrypoint graph. Walking the static graph catches the regression a timing assertion would
* measure inconsistently across machines.
*/

const REPO_ROOT = resolve(import.meta.dir, "../..");
const ENTRYPOINT = join(REPO_ROOT, "src/main.tsx");

/**
* Package prefixes the entrypoint must not load before a command selects an interactive plan.
*
* These are the heavy graphs named in the startup-deferral contract: the Pierre diff engine and
* its renderer, and OpenTUI's embedded native library.
*/
const DEFERRED_PACKAGE_PREFIXES = ["@pierre/", "@opentui/"];

const MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];

/** Resolve one relative specifier to a source file the walker can read. */
function resolveLocalModule(fromFile: string, specifier: string) {
const base = resolve(dirname(fromFile), specifier);
const candidates = [
base,
...MODULE_EXTENSIONS.map((extension) => `${base}${extension}`),
...MODULE_EXTENSIONS.map((extension) => join(base, `index${extension}`)),
];

return (
candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile()) ?? null
);
}

/** Strip the entrypoint shebang so the transpiler can scan it as a module. */
function readModuleSource(file: string) {
const source = readFileSync(file, "utf8");
return source.startsWith("#!") ? source.slice(source.indexOf("\n") + 1) : source;
}

/**
* Walk every eagerly imported module reachable from the entrypoint.
*
* `scanImports` reports the imports that survive transpilation, so type-only imports are already
* excluded, and `dynamic-import` entries are skipped because those are exactly the deferral
* mechanism under test. Returns each external package with the chain that first reached it.
*/
function traceEagerExternals(entrypoint: string) {
const transpiler = new Transpiler({ loader: "tsx" });
const visited = new Set<string>();
const externals = new Map<string, string[]>();

const walk = (file: string, chain: string[]) => {
if (visited.has(file) || file.endsWith(".json")) {
return;
}
visited.add(file);

for (const imported of transpiler.scanImports(readModuleSource(file))) {
if (imported.kind !== "import-statement") {
continue;
}

if (imported.path.startsWith(".")) {
const next = resolveLocalModule(file, imported.path);
if (next) {
walk(next, [...chain, relative(REPO_ROOT, next)]);
}
continue;
}

if (!externals.has(imported.path)) {
externals.set(imported.path, [...chain, imported.path]);
}
}
};

walk(entrypoint, [relative(REPO_ROOT, entrypoint)]);
return externals;
}

describe("CLI startup graph", () => {
test("entrypoint does not eagerly import the diff engine or renderer", () => {
const externals = traceEagerExternals(ENTRYPOINT);
const eagerlyDeferred = [...externals.entries()]
.filter(([packageName]) =>
DEFERRED_PACKAGE_PREFIXES.some((prefix) => packageName.startsWith(prefix)),
)
.map(([packageName, chain]) => `${packageName} via ${chain.join(" -> ")}`);

expect(eagerlyDeferred).toEqual([]);
});

test("worker disposal stays with the interactive app rather than the entrypoint", () => {
// The entrypoint resolves once the app is mounted, so disposing from there would terminate the
// worker before the first large diff requested it.
const interactiveAppSource = readFileSync(
join(REPO_ROOT, "src/ui/runInteractiveApp.tsx"),
"utf8",
);

expect(readModuleSource(ENTRYPOINT).includes("disposeHighlightWorker")).toBe(false);
expect(interactiveAppSource.includes("disposeHighlightWorker()")).toBe(true);
});
});
Loading