oh-my-opencode 4.17.1 → 4.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/codex-qa/SKILL.md +2 -0
- package/.agents/skills/codex-qa/scripts/lsp-e2e.sh +3654 -0
- package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +3071 -0
- package/.agents/skills/work-with-pr/SKILL.md +16 -37
- package/.agents/skills/work-with-pr-workspace/evals/evals.json +3 -3
- package/.opencode/skills/work-with-pr/SKILL.md +16 -37
- package/.opencode/skills/work-with-pr-workspace/evals/evals.json +3 -3
- package/dist/cli/get-local-version/types.d.ts +1 -1
- package/dist/cli/index.js +498 -165
- package/dist/cli-node/index.js +498 -165
- package/dist/index.js +425 -392
- package/dist/skills/frontend/SKILL.md +1 -1
- package/dist/skills/frontend/references/design/README.md +9 -0
- package/dist/skills/frontend/references/design/design-system-architecture.md +4 -2
- package/dist/skills/frontend/references/design/layout-skill.md +107 -0
- package/dist/skills/programming/SKILL.md +12 -2
- package/package.json +17 -16
- package/packages/lsp-core/package.json +4 -0
- package/packages/lsp-core/src/index.ts +1 -0
- package/packages/lsp-core/src/lsp/cleanup-errors.test.ts +18 -0
- package/packages/lsp-core/src/lsp/cleanup-errors.ts +12 -3
- package/packages/lsp-core/src/lsp/client-diagnostics-freshness.integration.test.ts +261 -0
- package/packages/lsp-core/src/lsp/client-wrapper.test.ts +63 -0
- package/packages/lsp-core/src/lsp/client-wrapper.ts +35 -5
- package/packages/lsp-core/src/lsp/client.ts +262 -80
- package/packages/lsp-core/src/lsp/config-loader.ts +5 -17
- package/packages/lsp-core/src/lsp/connection.ts +12 -6
- package/packages/lsp-core/src/lsp/directory-diagnostics.test.ts +104 -0
- package/packages/lsp-core/src/lsp/directory-diagnostics.ts +60 -27
- package/packages/lsp-core/src/lsp/errors.ts +11 -0
- package/packages/lsp-core/src/lsp/fixtures/diagnostics-freshness-contract-probe.ts +283 -0
- package/packages/lsp-core/src/lsp/fixtures/workspace-edit-contract-probe.ts +196 -0
- package/packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs +215 -0
- package/packages/lsp-core/src/lsp/formatters.ts +3 -0
- package/packages/lsp-core/src/lsp/json-rpc-connection-cancellation.test.ts +97 -0
- package/packages/lsp-core/src/lsp/json-rpc-connection.ts +73 -5
- package/packages/lsp-core/src/lsp/server-install-state.ts +3 -6
- package/packages/lsp-core/src/lsp/transport-protocol.ts +52 -0
- package/packages/lsp-core/src/lsp/transport.ts +96 -70
- package/packages/lsp-core/src/lsp/workspace-apply-edit-failure.ts +19 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-lease.integration.test.ts +214 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-sync.integration.test.ts +113 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-test-support.ts +163 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit.integration.test.ts +163 -0
- package/packages/lsp-core/src/lsp/workspace-document-state.test.ts +67 -0
- package/packages/lsp-core/src/lsp/workspace-document-state.ts +368 -0
- package/packages/lsp-core/src/lsp/workspace-edit-adversarial.test.ts +113 -0
- package/packages/lsp-core/src/lsp/workspace-edit-commit.test.ts +140 -0
- package/packages/lsp-core/src/lsp/workspace-edit-commit.ts +220 -0
- package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.test.ts +56 -0
- package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.ts +30 -0
- package/packages/lsp-core/src/lsp/workspace-edit-fingerprint.ts +44 -0
- package/packages/lsp-core/src/lsp/workspace-edit-options.test.ts +147 -0
- package/packages/lsp-core/src/lsp/workspace-edit-parse-helpers.ts +59 -0
- package/packages/lsp-core/src/lsp/workspace-edit-parser.ts +130 -0
- package/packages/lsp-core/src/lsp/workspace-edit-path.ts +98 -0
- package/packages/lsp-core/src/lsp/workspace-edit-plan-types.ts +60 -0
- package/packages/lsp-core/src/lsp/workspace-edit-plan.ts +73 -0
- package/packages/lsp-core/src/lsp/workspace-edit-prevalidation.test.ts +174 -0
- package/packages/lsp-core/src/lsp/workspace-edit-resource-parser.ts +89 -0
- package/packages/lsp-core/src/lsp/workspace-edit-simulation.ts +183 -0
- package/packages/lsp-core/src/lsp/workspace-edit-snapshot.ts +53 -0
- package/packages/lsp-core/src/lsp/workspace-edit-text.ts +125 -0
- package/packages/lsp-core/src/lsp/workspace-edit-types.ts +121 -0
- package/packages/lsp-core/src/lsp/workspace-edit.characterization.test.ts +95 -0
- package/packages/lsp-core/src/lsp/workspace-edit.ts +49 -200
- package/packages/lsp-core/src/lsp/workspace-mutation-controller.ts +182 -0
- package/packages/lsp-core/src/mcp.ts +18 -7
- package/packages/lsp-core/src/missing-dependency-result.test.ts +105 -0
- package/packages/lsp-core/src/missing-dependency-result.ts +57 -0
- package/packages/lsp-core/src/post-edit/index.ts +1 -0
- package/packages/lsp-core/src/post-edit/orchestration.test.ts +157 -0
- package/packages/lsp-core/src/post-edit/orchestration.ts +178 -0
- package/packages/lsp-core/src/request-context.test.ts +171 -0
- package/packages/lsp-core/src/request-context.ts +222 -9
- package/packages/lsp-core/src/tool-surface.test.ts +4 -1
- package/packages/lsp-core/src/tools/diagnostics.ts +32 -13
- package/packages/lsp-core/src/tools/navigation.ts +12 -12
- package/packages/lsp-core/src/tools/rename.ts +10 -15
- package/packages/lsp-core/src/tools/symbols.ts +11 -11
- package/packages/lsp-core/src/tools/types.ts +2 -1
- package/packages/lsp-daemon/dist/cli.js +3114 -747
- package/packages/lsp-daemon/dist/client.d.ts +105 -0
- package/packages/lsp-daemon/dist/client.js +5851 -0
- package/packages/lsp-daemon/dist/daemon-client.d.ts +11 -6
- package/packages/lsp-daemon/dist/daemon-client.js +113 -30
- package/packages/lsp-daemon/dist/daemon-server.d.ts +1 -0
- package/packages/lsp-daemon/dist/daemon-server.js +40 -15
- package/packages/lsp-daemon/dist/ensure-daemon.d.ts +8 -7
- package/packages/lsp-daemon/dist/ensure-daemon.js +67 -44
- package/packages/lsp-daemon/dist/index.d.ts +2 -2
- package/packages/lsp-daemon/dist/index.js +2862 -754
- package/packages/lsp-daemon/dist/ipc-protocol.d.ts +46 -0
- package/packages/lsp-daemon/dist/ipc-protocol.js +187 -0
- package/packages/lsp-daemon/dist/lock.js +14 -4
- package/packages/lsp-daemon/dist/ownership.d.ts +49 -0
- package/packages/lsp-daemon/dist/ownership.js +168 -0
- package/packages/lsp-daemon/dist/paths.d.ts +33 -9
- package/packages/lsp-daemon/dist/paths.js +72 -33
- package/packages/lsp-daemon/dist/proxy.d.ts +3 -0
- package/packages/lsp-daemon/dist/proxy.js +54 -3
- package/packages/lsp-daemon/dist/request-routing.d.ts +7 -2
- package/packages/lsp-daemon/dist/request-routing.js +71 -22
- package/packages/lsp-daemon/dist/run-daemon.js +9 -2
- package/packages/lsp-daemon/dist/runtime-contract.d.ts +21 -0
- package/packages/lsp-daemon/dist/runtime-contract.js +58 -0
- package/packages/lsp-daemon/dist/socket-jsonrpc.js +6 -1
- package/packages/lsp-daemon/package.json +12 -3
- package/packages/lsp-tools-mcp/dist/cli.js +2115 -442
- package/packages/lsp-tools-mcp/dist/lsp/manager.js +1741 -148
- package/packages/lsp-tools-mcp/dist/mcp.js +2127 -454
- package/packages/lsp-tools-mcp/dist/request-context.js +176 -6
- package/packages/lsp-tools-mcp/dist/tools.js +2118 -446
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +55 -0
- package/packages/omo-codex/plugin/components/lsp/dist/cli.js +2959 -944
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook-cli.js +0 -4
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.d.ts +5 -2
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.js +41 -62
- package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.d.ts +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.js +24 -15
- package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.d.ts +3 -7
- package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.js +23 -49
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +3 -2
- package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.mjs +31 -1
- package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.test.mjs +76 -0
- package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.mjs +201 -0
- package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.test.mjs +55 -0
- package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +0 -4
- package/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +49 -71
- package/packages/omo-codex/plugin/components/lsp/src/daemon-cli-path.ts +26 -15
- package/packages/omo-codex/plugin/components/lsp/src/lsp-session-state.ts +26 -64
- package/packages/omo-codex/plugin/components/lsp/test/codex-hook-unavailable.test.ts +16 -17
- package/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts +30 -4
- package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +19 -5
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +8 -6
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/directive.md +37 -10
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +37 -10
- package/packages/omo-codex/plugin/components/ulw-loop/directive.md +37 -10
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +2 -2
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +10 -9
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +26 -14
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs +2 -3
- package/packages/omo-codex/plugin/scripts/build-components.mjs +13 -1
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +1 -1
- package/packages/omo-codex/plugin/skills/frontend/SKILL.md +1 -1
- package/packages/omo-codex/plugin/skills/frontend/references/design/README.md +9 -0
- package/packages/omo-codex/plugin/skills/frontend/references/design/design-system-architecture.md +4 -2
- package/packages/omo-codex/plugin/skills/frontend/references/design/layout-skill.md +107 -0
- package/packages/omo-codex/plugin/skills/programming/SKILL.md +12 -2
- package/packages/omo-codex/plugin/skills/start-work/SKILL.md +1 -1
- package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +37 -10
- package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +2 -2
- package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +10 -9
- package/packages/omo-codex/plugin/test/aggregate-build.test.mjs +8 -0
- package/packages/omo-codex/plugin/test/component-bundled-cli.test.mjs +128 -15
- package/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs +10 -0
- package/packages/omo-codex/plugin/test/lsp-prebuild-layouts.test.mjs +2 -0
- package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +1 -1
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +351 -74
- package/packages/shared-skills/skills/frontend/SKILL.md +1 -1
- package/packages/shared-skills/skills/frontend/references/design/README.md +9 -0
- package/packages/shared-skills/skills/frontend/references/design/design-system-architecture.md +4 -2
- package/packages/shared-skills/skills/frontend/references/design/layout-skill.md +107 -0
- package/packages/shared-skills/skills/programming/SKILL.md +12 -2
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createWorkspaceEditTestHarness,
|
|
6
|
+
diagnostic,
|
|
7
|
+
readEvents,
|
|
8
|
+
renameTextEdit,
|
|
9
|
+
waitForEventCount,
|
|
10
|
+
} from "../workspace-apply-edit-test-support.js";
|
|
11
|
+
import { workspaceEditContractFailureEvidence } from "../workspace-edit-contract-evidence.js";
|
|
12
|
+
|
|
13
|
+
class WorkspaceEditContractProbeError extends Error {
|
|
14
|
+
override readonly name = "WorkspaceEditContractProbeError";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireProbe(condition: boolean, message: string): asserts condition {
|
|
18
|
+
if (!condition) throw new WorkspaceEditContractProbeError(message);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function applyResponses(eventsPath: string): readonly unknown[] {
|
|
26
|
+
return readEvents(eventsPath)
|
|
27
|
+
.filter((event) => event.type === "clientResponse" && event.method === "workspace/applyEdit")
|
|
28
|
+
.map((event) => event.result);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isRejectedApply(value: unknown): boolean {
|
|
32
|
+
return isRecord(value) && value["applied"] === false && typeof value["failureReason"] === "string";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function didChangeVersions(eventsPath: string): readonly number[] {
|
|
36
|
+
const versions: number[] = [];
|
|
37
|
+
for (const event of readEvents(eventsPath)) {
|
|
38
|
+
if (event.type !== "clientNotification" || event.method !== "textDocument/didChange") continue;
|
|
39
|
+
if (!isRecord(event.params)) continue;
|
|
40
|
+
const textDocument = event.params["textDocument"];
|
|
41
|
+
if (!isRecord(textDocument) || typeof textDocument["version"] !== "number") continue;
|
|
42
|
+
versions.push(textDocument["version"]);
|
|
43
|
+
}
|
|
44
|
+
return versions;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const outputPath = process.argv[2];
|
|
48
|
+
if (!outputPath) throw new WorkspaceEditContractProbeError("output path is required");
|
|
49
|
+
|
|
50
|
+
const harness = createWorkspaceEditTestHarness();
|
|
51
|
+
try {
|
|
52
|
+
const success = await harness.makeClient({
|
|
53
|
+
renameSteps: [{ applyEdit: renameTextEdit("before", "after", { version: 1 }), renameResult: "same" }],
|
|
54
|
+
diagnostics: [diagnostic("todo3-fresh")],
|
|
55
|
+
});
|
|
56
|
+
let successWrites = 0;
|
|
57
|
+
success.client.setWorkspaceEditIo({
|
|
58
|
+
writeFile(path, content) {
|
|
59
|
+
successWrites += 1;
|
|
60
|
+
writeFileSync(path, content, "utf-8");
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
await success.client.openFile(success.source);
|
|
64
|
+
const successResult = await success.client.rename(success.source, 1, 6, "after");
|
|
65
|
+
const successResponses = applyResponses(success.events);
|
|
66
|
+
const successVersions = didChangeVersions(success.events);
|
|
67
|
+
const successUri = pathToFileURL(realpathSync(success.source)).href;
|
|
68
|
+
const successEvidence = {
|
|
69
|
+
serverAppliedRename:
|
|
70
|
+
successResponses.length === 1 && isRecord(successResponses[0]) && successResponses[0]["applied"] === true,
|
|
71
|
+
recordedResultReused: successResult.edit !== null && successResult.apply.success && successWrites === 1,
|
|
72
|
+
writeCount: successWrites,
|
|
73
|
+
secondWritePrevented: successWrites === 1 && successVersions.length === 1,
|
|
74
|
+
synchronizedDocumentVersion: success.client.getOpenDocumentVersion(success.source) ?? null,
|
|
75
|
+
didChangeVersions: successVersions,
|
|
76
|
+
immediateDiagnostics: success.client
|
|
77
|
+
.getStoredDiagnostics(successUri)
|
|
78
|
+
.some((entry) => entry.message === "todo3-fresh"),
|
|
79
|
+
finalContent: readFileSync(success.source, "utf-8"),
|
|
80
|
+
};
|
|
81
|
+
requireProbe(successEvidence.serverAppliedRename, "successful server apply was not reported truthfully");
|
|
82
|
+
requireProbe(successEvidence.recordedResultReused, "rename did not reuse the recorded server result");
|
|
83
|
+
requireProbe(successEvidence.secondWritePrevented, "server edit was written or synchronized more than once");
|
|
84
|
+
requireProbe(successEvidence.synchronizedDocumentVersion === 2, "open document version did not advance to 2");
|
|
85
|
+
requireProbe(successEvidence.immediateDiagnostics, "fresh diagnostics were not visible immediately after applyEdit");
|
|
86
|
+
requireProbe(successEvidence.finalContent === "const after = 1;\n", "successful rename produced unexpected bytes");
|
|
87
|
+
|
|
88
|
+
const unscoped = await harness.makeClient({
|
|
89
|
+
unscopedApplyEdit: renameTextEdit("before", "after", { version: 1 }),
|
|
90
|
+
});
|
|
91
|
+
const unscopedResponses = await waitForEventCount(
|
|
92
|
+
unscoped.events,
|
|
93
|
+
(event) => event.type === "clientResponse" && event.method === "workspace/applyEdit",
|
|
94
|
+
1,
|
|
95
|
+
);
|
|
96
|
+
const unscopedFailure = unscopedResponses[0]?.result;
|
|
97
|
+
requireProbe(isRejectedApply(unscopedFailure), "unscoped applyEdit was not rejected");
|
|
98
|
+
requireProbe(readFileSync(unscoped.source, "utf-8") === "const before = 1;\n", "unscoped failure mutated bytes");
|
|
99
|
+
|
|
100
|
+
const concurrent = await harness.makeClient({
|
|
101
|
+
renameSteps: [
|
|
102
|
+
{
|
|
103
|
+
applyEdit: renameTextEdit("before", "after", { version: 1 }),
|
|
104
|
+
applyConcurrently: true,
|
|
105
|
+
renameResult: "same",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
let concurrentWrites = 0;
|
|
110
|
+
concurrent.client.setWorkspaceEditIo({
|
|
111
|
+
writeFile(path, content) {
|
|
112
|
+
concurrentWrites += 1;
|
|
113
|
+
writeFileSync(path, content, "utf-8");
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
await concurrent.client.openFile(concurrent.source);
|
|
117
|
+
const concurrentResult = await concurrent.client.rename(concurrent.source, 1, 6, "after");
|
|
118
|
+
const concurrentFailure = applyResponses(concurrent.events).find(isRejectedApply);
|
|
119
|
+
requireProbe(concurrentResult.apply.success, "the winning concurrent applyEdit did not complete");
|
|
120
|
+
requireProbe(concurrentWrites === 1, "concurrent applyEdit wrote more than once");
|
|
121
|
+
requireProbe(isRejectedApply(concurrentFailure), "concurrent applyEdit loser was not rejected");
|
|
122
|
+
|
|
123
|
+
const mismatched = await harness.makeClient({
|
|
124
|
+
renameSteps: [{ applyEdit: renameTextEdit("before", "after", { version: 9 }), renameResult: "same" }],
|
|
125
|
+
});
|
|
126
|
+
await mismatched.client.openFile(mismatched.source);
|
|
127
|
+
const mismatchedResult = await mismatched.client.rename(mismatched.source, 1, 6, "after");
|
|
128
|
+
const mismatchedFailure = applyResponses(mismatched.events).find(isRejectedApply);
|
|
129
|
+
requireProbe(!mismatchedResult.apply.success, "mismatched document version unexpectedly succeeded");
|
|
130
|
+
requireProbe(isRejectedApply(mismatchedFailure), "mismatched version was not rejected over applyEdit");
|
|
131
|
+
requireProbe(readFileSync(mismatched.source, "utf-8") === "const before = 1;\n", "version failure mutated bytes");
|
|
132
|
+
|
|
133
|
+
const preGate = await harness.makeClient({
|
|
134
|
+
renameSteps: [
|
|
135
|
+
{
|
|
136
|
+
applyEdit: renameTextEdit("before", "after", { version: 1 }),
|
|
137
|
+
applyDelayMs: 100,
|
|
138
|
+
renameResult: "same",
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
await preGate.client.openFile(preGate.source);
|
|
144
|
+
const pendingRename = preGate.client.rename(preGate.source, 1, 6, "after", controller.signal);
|
|
145
|
+
await waitForEventCount(
|
|
146
|
+
preGate.events,
|
|
147
|
+
(event) => event.type === "clientRequest" && event.method === "textDocument/rename",
|
|
148
|
+
1,
|
|
149
|
+
);
|
|
150
|
+
controller.abort();
|
|
151
|
+
const preGateError = await pendingRename.then(
|
|
152
|
+
() => null,
|
|
153
|
+
(error: unknown) => error,
|
|
154
|
+
);
|
|
155
|
+
const preGateMessage = preGateError instanceof Error ? preGateError.message : String(preGateError);
|
|
156
|
+
const preGateFailure = {
|
|
157
|
+
success: false,
|
|
158
|
+
filesModified: [],
|
|
159
|
+
totalEdits: 0,
|
|
160
|
+
errors: [preGateMessage],
|
|
161
|
+
};
|
|
162
|
+
requireProbe(preGateError !== null, "pre-gate cancellation unexpectedly succeeded");
|
|
163
|
+
requireProbe(
|
|
164
|
+
preGateFailure.errors.some((message) => /cancel|abort/i.test(message)),
|
|
165
|
+
"pre-gate cancellation returned the wrong failure",
|
|
166
|
+
);
|
|
167
|
+
requireProbe(readFileSync(preGate.source, "utf-8") === "const before = 1;\n", "pre-gate cancellation mutated bytes");
|
|
168
|
+
|
|
169
|
+
const failureCases = {
|
|
170
|
+
unscoped: workspaceEditContractFailureEvidence(unscopedFailure, unscoped.workspace),
|
|
171
|
+
concurrent: workspaceEditContractFailureEvidence(concurrentFailure, concurrent.workspace),
|
|
172
|
+
mismatched: workspaceEditContractFailureEvidence(mismatchedFailure, mismatched.workspace),
|
|
173
|
+
preGate: workspaceEditContractFailureEvidence(preGateFailure, preGate.workspace),
|
|
174
|
+
};
|
|
175
|
+
writeFileSync(
|
|
176
|
+
outputPath,
|
|
177
|
+
`${JSON.stringify(
|
|
178
|
+
{
|
|
179
|
+
schemaVersion: 1,
|
|
180
|
+
success: successEvidence,
|
|
181
|
+
failureCases,
|
|
182
|
+
failureHashes: {
|
|
183
|
+
unscoped: failureCases.unscoped.sha256,
|
|
184
|
+
concurrent: failureCases.concurrent.sha256,
|
|
185
|
+
mismatched: failureCases.mismatched.sha256,
|
|
186
|
+
preGate: failureCases.preGate.sha256,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
null,
|
|
190
|
+
2,
|
|
191
|
+
)}\n`,
|
|
192
|
+
"utf-8",
|
|
193
|
+
);
|
|
194
|
+
} finally {
|
|
195
|
+
await harness.cleanup();
|
|
196
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const [, , scenarioPath, eventsPath] = process.argv;
|
|
4
|
+
const scenario = JSON.parse(readFileSync(scenarioPath, "utf-8"));
|
|
5
|
+
|
|
6
|
+
let buffer = Buffer.alloc(0);
|
|
7
|
+
let nextServerRequestId = 10_000;
|
|
8
|
+
let renameIndex = 0;
|
|
9
|
+
let diagnosticIndex = 0;
|
|
10
|
+
const pendingServerRequests = new Map();
|
|
11
|
+
|
|
12
|
+
function record(event) {
|
|
13
|
+
appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, "utf-8");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function send(message) {
|
|
17
|
+
const body = Buffer.from(JSON.stringify(message), "utf-8");
|
|
18
|
+
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
|
|
19
|
+
process.stdout.write(body);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sendResponse(id, result) {
|
|
23
|
+
send({ jsonrpc: "2.0", id, result });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sendError(id, code, message) {
|
|
27
|
+
send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sendApplyEdit(edit, context) {
|
|
31
|
+
const id = nextServerRequestId;
|
|
32
|
+
nextServerRequestId += 1;
|
|
33
|
+
pendingServerRequests.set(String(id), context);
|
|
34
|
+
record({ type: "serverRequest", method: "workspace/applyEdit", id, edit });
|
|
35
|
+
send({ jsonrpc: "2.0", id, method: "workspace/applyEdit", params: { edit } });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renameResult(step) {
|
|
39
|
+
if (step.renameResult === "same") return step.applyEdit ?? null;
|
|
40
|
+
if (step.renameResult === "different") return step.differentEdit ?? null;
|
|
41
|
+
if (step.renameResult === "edit") return step.renameEdit ?? null;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function finishRename(context) {
|
|
46
|
+
const result = renameResult(context.step);
|
|
47
|
+
const delay = Number(context.step.responseDelayMs ?? 0);
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
record({ type: "serverResponse", method: "textDocument/rename", result });
|
|
50
|
+
sendResponse(context.renameRequestId, result);
|
|
51
|
+
}, delay);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function schedulePublish(step, fallbackUri, fallbackVersion) {
|
|
55
|
+
const delay = Number(step.delayMs ?? 0);
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
const params = {
|
|
58
|
+
uri: step.uri ?? fallbackUri,
|
|
59
|
+
...(step.version === undefined ? {} : { version: step.version }),
|
|
60
|
+
diagnostics: Array.isArray(step.diagnostics) ? step.diagnostics : [],
|
|
61
|
+
};
|
|
62
|
+
record({ type: "serverNotification", method: "textDocument/publishDiagnostics", params });
|
|
63
|
+
send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params });
|
|
64
|
+
}, delay);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function publishSteps(trigger) {
|
|
68
|
+
if (!Array.isArray(scenario.publishDiagnostics)) return [];
|
|
69
|
+
return scenario.publishDiagnostics.filter((step) => step?.trigger === trigger);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function applyPublishSteps(trigger, fallbackUri, fallbackVersion) {
|
|
73
|
+
for (const step of publishSteps(trigger)) schedulePublish(step, fallbackUri, fallbackVersion);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function handleClientResponse(message) {
|
|
77
|
+
const context = pendingServerRequests.get(String(message.id));
|
|
78
|
+
if (!context) return;
|
|
79
|
+
pendingServerRequests.delete(String(message.id));
|
|
80
|
+
record({
|
|
81
|
+
type: "clientResponse",
|
|
82
|
+
method: "workspace/applyEdit",
|
|
83
|
+
result: message.result ?? null,
|
|
84
|
+
error: message.error ?? null,
|
|
85
|
+
});
|
|
86
|
+
if (context.kind === "unscoped") return;
|
|
87
|
+
if (typeof context.pendingApplyResponses === "number") {
|
|
88
|
+
context.pendingApplyResponses -= 1;
|
|
89
|
+
if (context.pendingApplyResponses > 0) return;
|
|
90
|
+
finishRename(context);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (context.step.applyTwice === true && context.applyCount === 1) {
|
|
94
|
+
sendApplyEdit(context.step.applyEdit, { ...context, applyCount: 2 });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
finishRename(context);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handleRequest(message) {
|
|
101
|
+
if (message.method === "initialize") {
|
|
102
|
+
record({ type: "clientRequest", method: message.method, id: message.id, params: message.params });
|
|
103
|
+
sendResponse(message.id, { capabilities: scenario.capabilities ?? {} });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (message.method === "textDocument/rename") {
|
|
107
|
+
const step = scenario.renameSteps?.[renameIndex] ?? {};
|
|
108
|
+
renameIndex += 1;
|
|
109
|
+
record({ type: "clientRequest", method: message.method, id: message.id, params: message.params, renameIndex });
|
|
110
|
+
if (step.applyEdit) {
|
|
111
|
+
const context = { kind: "rename", renameRequestId: message.id, step, applyCount: 1 };
|
|
112
|
+
if (step.applyConcurrently === true) {
|
|
113
|
+
context.pendingApplyResponses = 2;
|
|
114
|
+
sendApplyEdit(step.applyEdit, context);
|
|
115
|
+
sendApplyEdit(step.applyEdit, context);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const delay = Number(step.applyDelayMs ?? 0);
|
|
119
|
+
if (delay > 0) {
|
|
120
|
+
setTimeout(() => sendApplyEdit(step.applyEdit, context), delay);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
sendApplyEdit(step.applyEdit, context);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
finishRename({ renameRequestId: message.id, step });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (message.method === "textDocument/diagnostic") {
|
|
130
|
+
record({ type: "clientRequest", method: message.method, id: message.id, params: message.params, diagnosticIndex: diagnosticIndex + 1 });
|
|
131
|
+
applyPublishSteps("diagnosticRequest", message.params?.textDocument?.uri, undefined);
|
|
132
|
+
const step = Array.isArray(scenario.diagnosticResponses) ? scenario.diagnosticResponses[diagnosticIndex] : undefined;
|
|
133
|
+
diagnosticIndex += 1;
|
|
134
|
+
if (step?.action === "hang") return;
|
|
135
|
+
if (step?.action === "close") {
|
|
136
|
+
const delay = Number(step.delayMs ?? 0);
|
|
137
|
+
setTimeout(() => process.exit(0), delay);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const delay = Number(step?.delayMs ?? 0);
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
if (step?.error) {
|
|
143
|
+
record({ type: "serverError", method: "textDocument/diagnostic", error: step.error });
|
|
144
|
+
sendError(message.id, step.error.code, step.error.message);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const result = step?.report ?? { items: scenario.diagnostics ?? [] };
|
|
148
|
+
record({ type: "serverResponse", method: "textDocument/diagnostic", result });
|
|
149
|
+
sendResponse(message.id, result);
|
|
150
|
+
}, delay);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (message.method === "shutdown") {
|
|
154
|
+
sendResponse(message.id, null);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
sendResponse(message.id, null);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function handleNotification(message) {
|
|
161
|
+
record({ type: "clientNotification", method: message.method, params: message.params ?? null });
|
|
162
|
+
if (message.method === "initialized" && scenario.unscopedApplyEdit) {
|
|
163
|
+
sendApplyEdit(scenario.unscopedApplyEdit, { kind: "unscoped" });
|
|
164
|
+
}
|
|
165
|
+
if (message.method === "initialized") {
|
|
166
|
+
applyPublishSteps("initialized", undefined, undefined);
|
|
167
|
+
}
|
|
168
|
+
if (message.method === "textDocument/didOpen") {
|
|
169
|
+
applyPublishSteps("didOpen", message.params.textDocument.uri, message.params.textDocument.version);
|
|
170
|
+
}
|
|
171
|
+
if (message.method === "textDocument/didChange" && Array.isArray(scenario.diagnostics)) {
|
|
172
|
+
send({
|
|
173
|
+
jsonrpc: "2.0",
|
|
174
|
+
method: "textDocument/publishDiagnostics",
|
|
175
|
+
params: {
|
|
176
|
+
uri: message.params.textDocument.uri,
|
|
177
|
+
version: message.params.textDocument.version,
|
|
178
|
+
diagnostics: scenario.diagnostics,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (message.method === "textDocument/didChange") {
|
|
183
|
+
applyPublishSteps("didChange", message.params.textDocument.uri, message.params.textDocument.version);
|
|
184
|
+
}
|
|
185
|
+
if (message.method === "exit") process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleMessage(message) {
|
|
189
|
+
if (Object.hasOwn(message, "id") && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error"))) {
|
|
190
|
+
handleClientResponse(message);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (Object.hasOwn(message, "id")) {
|
|
194
|
+
handleRequest(message);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
handleNotification(message);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
process.stdin.on("data", (chunk) => {
|
|
201
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
202
|
+
for (;;) {
|
|
203
|
+
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
204
|
+
if (headerEnd === -1) return;
|
|
205
|
+
const header = buffer.subarray(0, headerEnd).toString("ascii");
|
|
206
|
+
const match = /content-length:\s*(\d+)/i.exec(header);
|
|
207
|
+
if (!match) process.exit(2);
|
|
208
|
+
const length = Number(match[1]);
|
|
209
|
+
const bodyStart = headerEnd + 4;
|
|
210
|
+
if (buffer.length < bodyStart + length) return;
|
|
211
|
+
const body = buffer.subarray(bodyStart, bodyStart + length).toString("utf-8");
|
|
212
|
+
buffer = buffer.subarray(bodyStart + length);
|
|
213
|
+
handleMessage(JSON.parse(body));
|
|
214
|
+
}
|
|
215
|
+
});
|
|
@@ -127,6 +127,9 @@ export function formatApplyResult(result: ApplyResult): string {
|
|
|
127
127
|
for (const file of result.filesModified) {
|
|
128
128
|
lines.push(` - ${file}`);
|
|
129
129
|
}
|
|
130
|
+
if (result.lateAbort) {
|
|
131
|
+
lines.push("Cancellation arrived after the filesystem commit began; the committed edit completed.");
|
|
132
|
+
}
|
|
130
133
|
} else {
|
|
131
134
|
lines.push("Failed to apply some changes:");
|
|
132
135
|
for (const err of result.errors) {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { PassThrough } from "node:stream";
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
|
|
5
|
+
import { JsonRpcConnection } from "./json-rpc-connection.js";
|
|
6
|
+
|
|
7
|
+
type JsonMessage = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
function messageId(message: JsonMessage): string | number | null | undefined {
|
|
10
|
+
const id = message["id"];
|
|
11
|
+
return typeof id === "string" || typeof id === "number" || id === null ? id : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function cancelTarget(message: JsonMessage): string | number | undefined {
|
|
15
|
+
const params = message["params"];
|
|
16
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) return undefined;
|
|
17
|
+
const id = Reflect.get(params, "id");
|
|
18
|
+
return typeof id === "string" || typeof id === "number" ? id : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function encodeMessage(message: JsonMessage): string {
|
|
22
|
+
const body = JSON.stringify(message);
|
|
23
|
+
return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function recordJsonRpcMessages(stream: PassThrough) {
|
|
27
|
+
const messages: JsonMessage[] = [];
|
|
28
|
+
const waiters: Array<() => void> = [];
|
|
29
|
+
let buffer = Buffer.alloc(0);
|
|
30
|
+
stream.on("data", (chunk: Buffer) => {
|
|
31
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
32
|
+
for (;;) {
|
|
33
|
+
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
34
|
+
if (headerEnd === -1) return;
|
|
35
|
+
const header = buffer.subarray(0, headerEnd).toString("ascii");
|
|
36
|
+
const match = /content-length:\s*(\d+)/i.exec(header);
|
|
37
|
+
if (!match?.[1]) throw new Error("missing content-length");
|
|
38
|
+
const bodyStart = headerEnd + 4;
|
|
39
|
+
const bodyEnd = bodyStart + Number.parseInt(match[1], 10);
|
|
40
|
+
if (buffer.length < bodyEnd) return;
|
|
41
|
+
const parsed: unknown = JSON.parse(buffer.subarray(bodyStart, bodyEnd).toString("utf8"));
|
|
42
|
+
buffer = buffer.subarray(bodyEnd);
|
|
43
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
44
|
+
messages.push(Object.fromEntries(Object.entries(parsed)));
|
|
45
|
+
}
|
|
46
|
+
for (const wake of waiters.splice(0)) wake();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
messages,
|
|
51
|
+
waitForCount(count: number): Promise<void> {
|
|
52
|
+
if (messages.length >= count) return Promise.resolve();
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
reject(new Error(`waited for ${count} JSON-RPC messages, observed ${messages.length}`));
|
|
56
|
+
}, 1_000);
|
|
57
|
+
const wake = (): void => {
|
|
58
|
+
if (messages.length < count) {
|
|
59
|
+
waiters.push(wake);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
resolve();
|
|
64
|
+
};
|
|
65
|
+
waiters.push(wake);
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("JsonRpcConnection cancellation", () => {
|
|
72
|
+
it("#given an active request #when its signal aborts #then cancel uses the same id and late responses are ignored", async () => {
|
|
73
|
+
const serverToClient = new PassThrough();
|
|
74
|
+
const clientToServer = new PassThrough();
|
|
75
|
+
const recorder = recordJsonRpcMessages(clientToServer);
|
|
76
|
+
const connection = new JsonRpcConnection(serverToClient, clientToServer);
|
|
77
|
+
connection.listen();
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
|
|
80
|
+
const request = connection.sendRequest("textDocument/diagnostic", { textDocument: { uri: "file:///a.ts" } }, {
|
|
81
|
+
signal: controller.signal,
|
|
82
|
+
});
|
|
83
|
+
await recorder.waitForCount(1);
|
|
84
|
+
const requestId = messageId(recorder.messages[0] ?? {});
|
|
85
|
+
expect(typeof requestId === "string" || typeof requestId === "number").toBe(true);
|
|
86
|
+
|
|
87
|
+
controller.abort(new Error("caller cancelled"));
|
|
88
|
+
await recorder.waitForCount(2);
|
|
89
|
+
await expect(request).rejects.toThrow("caller cancelled");
|
|
90
|
+
expect(connection.pendingRequestCount()).toBe(0);
|
|
91
|
+
expect(recorder.messages[1]?.["method"]).toBe("$/cancelRequest");
|
|
92
|
+
expect(cancelTarget(recorder.messages[1] ?? {})).toBe(typeof requestId === "number" || typeof requestId === "string" ? requestId : undefined);
|
|
93
|
+
|
|
94
|
+
serverToClient.write(encodeMessage({ jsonrpc: "2.0", id: requestId ?? null, result: { items: [] } }));
|
|
95
|
+
expect(connection.pendingRequestCount()).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -3,11 +3,16 @@ type JsonRpcId = number | string | null;
|
|
|
3
3
|
interface PendingRequest {
|
|
4
4
|
resolve(result: unknown): void;
|
|
5
5
|
reject(error: Error): void;
|
|
6
|
+
cleanup(): void;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
type NotificationHandler = (params: unknown) => void;
|
|
9
10
|
type RequestHandler = (params: unknown) => Promise<unknown> | unknown;
|
|
10
11
|
|
|
12
|
+
export interface JsonRpcRequestOptions {
|
|
13
|
+
readonly signal?: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
const HEADER_SEPARATOR = "\r\n\r\n";
|
|
12
17
|
const PARSE_ERROR = -32700;
|
|
13
18
|
const INVALID_REQUEST = -32600;
|
|
@@ -56,32 +61,81 @@ export class JsonRpcConnection {
|
|
|
56
61
|
this.errorHandlers.push(handler);
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
async sendRequest<T>(method: string, params?: unknown): Promise<T> {
|
|
64
|
+
async sendRequest<T>(method: string, params?: unknown, options: JsonRpcRequestOptions = {}): Promise<T> {
|
|
60
65
|
if (this.disposed) throw new Error("JSON-RPC connection is disposed");
|
|
61
66
|
|
|
62
67
|
const id = this.nextRequestId;
|
|
63
68
|
this.nextRequestId += 1;
|
|
69
|
+
const key = String(id);
|
|
64
70
|
const message = params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params };
|
|
71
|
+
let requestWritten = false;
|
|
72
|
+
let cancelAfterWrite = false;
|
|
73
|
+
let settled = false;
|
|
74
|
+
const writeCancel = (): Promise<void> => this.writeMessage({ jsonrpc: "2.0", method: "$/cancelRequest", params: { id } });
|
|
65
75
|
|
|
66
76
|
const responsePromise = new Promise<T>((resolve, reject) => {
|
|
67
|
-
|
|
77
|
+
const cleanup = (): void => {
|
|
78
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
79
|
+
};
|
|
80
|
+
const settleCancel = (): void => {
|
|
81
|
+
if (settled) return;
|
|
82
|
+
settled = true;
|
|
83
|
+
this.pendingRequests.delete(key);
|
|
84
|
+
cleanup();
|
|
85
|
+
const rejectCancelled = (): void => reject(abortError(options.signal));
|
|
86
|
+
if (!requestWritten) {
|
|
87
|
+
cancelAfterWrite = true;
|
|
88
|
+
rejectCancelled();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
void writeCancel().then(rejectCancelled, (error) => {
|
|
92
|
+
this.emitError(toError(error));
|
|
93
|
+
rejectCancelled();
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
const onAbort = (): void => settleCancel();
|
|
97
|
+
this.pendingRequests.set(key, {
|
|
68
98
|
resolve(result) {
|
|
99
|
+
settled = true;
|
|
100
|
+
cleanup();
|
|
69
101
|
resolve(result as T);
|
|
70
102
|
},
|
|
71
|
-
reject
|
|
103
|
+
reject(error) {
|
|
104
|
+
settled = true;
|
|
105
|
+
cleanup();
|
|
106
|
+
reject(error);
|
|
107
|
+
},
|
|
108
|
+
cleanup,
|
|
72
109
|
});
|
|
110
|
+
if (options.signal?.aborted) {
|
|
111
|
+
settleCancel();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
73
115
|
});
|
|
74
116
|
|
|
117
|
+
if (settled) return responsePromise;
|
|
75
118
|
try {
|
|
76
119
|
await this.writeMessage(message);
|
|
120
|
+
requestWritten = true;
|
|
121
|
+
if (cancelAfterWrite) await writeCancel();
|
|
77
122
|
} catch (error) {
|
|
78
|
-
|
|
123
|
+
if (settled) return responsePromise;
|
|
124
|
+
const pending = this.pendingRequests.get(key);
|
|
125
|
+
if (pending) {
|
|
126
|
+
pending.cleanup();
|
|
127
|
+
this.pendingRequests.delete(key);
|
|
128
|
+
}
|
|
79
129
|
throw error;
|
|
80
130
|
}
|
|
81
131
|
|
|
82
132
|
return responsePromise;
|
|
83
133
|
}
|
|
84
134
|
|
|
135
|
+
pendingRequestCount(): number {
|
|
136
|
+
return this.pendingRequests.size;
|
|
137
|
+
}
|
|
138
|
+
|
|
85
139
|
async sendNotification(method: string, params?: unknown): Promise<void> {
|
|
86
140
|
if (this.disposed) return;
|
|
87
141
|
const message = params === undefined ? { jsonrpc: "2.0", method } : { jsonrpc: "2.0", method, params };
|
|
@@ -97,6 +151,7 @@ export class JsonRpcConnection {
|
|
|
97
151
|
this.reader.off("error", this.handleStreamError);
|
|
98
152
|
this.writer.off("error", this.handleStreamError);
|
|
99
153
|
for (const pending of this.pendingRequests.values()) {
|
|
154
|
+
pending.cleanup();
|
|
100
155
|
pending.reject(new Error("JSON-RPC connection disposed"));
|
|
101
156
|
}
|
|
102
157
|
this.pendingRequests.clear();
|
|
@@ -188,6 +243,7 @@ export class JsonRpcConnection {
|
|
|
188
243
|
const pending = this.pendingRequests.get(String(id));
|
|
189
244
|
if (!pending) return;
|
|
190
245
|
this.pendingRequests.delete(String(id));
|
|
246
|
+
pending.cleanup();
|
|
191
247
|
|
|
192
248
|
if ("error" in message) {
|
|
193
249
|
pending.reject(jsonRpcErrorToError(message["error"]));
|
|
@@ -203,7 +259,11 @@ export class JsonRpcConnection {
|
|
|
203
259
|
try {
|
|
204
260
|
handler(params);
|
|
205
261
|
} catch (error) {
|
|
206
|
-
|
|
262
|
+
if (error instanceof Error) {
|
|
263
|
+
this.emitError(error);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
this.emitError(new Error(String(error)));
|
|
207
267
|
}
|
|
208
268
|
}
|
|
209
269
|
|
|
@@ -259,6 +319,14 @@ export class JsonRpcConnection {
|
|
|
259
319
|
}
|
|
260
320
|
}
|
|
261
321
|
|
|
322
|
+
function abortError(signal: AbortSignal | undefined): Error {
|
|
323
|
+
const reason = signal?.reason;
|
|
324
|
+
if (reason instanceof Error) return reason;
|
|
325
|
+
const error = new Error(typeof reason === "string" ? reason : "LSP request cancelled");
|
|
326
|
+
error.name = "AbortError";
|
|
327
|
+
return error;
|
|
328
|
+
}
|
|
329
|
+
|
|
262
330
|
function parseContentLength(headers: string): number | null {
|
|
263
331
|
for (const line of headers.split("\r\n")) {
|
|
264
332
|
const separatorIndex = line.indexOf(":");
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, isAbsolute, join } from "node:path";
|
|
2
|
+
import { dirname } from "node:path";
|
|
4
3
|
|
|
5
|
-
import {
|
|
4
|
+
import { lspRequestContext } from "../request-context.js";
|
|
6
5
|
|
|
7
6
|
export type InstallDecision = "declined" | "allowed";
|
|
8
7
|
|
|
@@ -14,9 +13,7 @@ export interface InstallDecisionRecord {
|
|
|
14
13
|
type InstallDecisions = Record<string, InstallDecisionRecord>;
|
|
15
14
|
|
|
16
15
|
export function getInstallDecisionsPath(): string {
|
|
17
|
-
|
|
18
|
-
if (!override) return join(homedir(), ".codex", "lsp-install-decisions.json");
|
|
19
|
-
return isAbsolute(override) ? override : join(homedir(), override);
|
|
16
|
+
return lspRequestContext().installDecisionsPath;
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
export function loadInstallDecisions(): InstallDecisions {
|