Skip to content

Commit 7a2aaa9

Browse files
author
ralphstodomingo
committed
fix(workspace): the newer add wins whichever completes first, and a global disable is honoured before the project write
Two more of the same shape, on the last review round. `MCP.createAndStore` guarded its failure path against a client another caller registered meanwhile, but not its success path: an older creation completing after a newer add stored its client, closing the newer one and handing the runtime back to what the older call was asked to start. The newer call now wins whichever completes first — a late result is closed, not stored, and the late call answers with what is serving. `persist` checked the node it was about to replace, which is the PROJECT file's; intent can also live in the global config the project inherits from, and a project pin written over a global disable shadows it for good, since project wins the merge. The merged view is asked once more, immediately before the write. Same window as the write's own read; named, not closed. The lifecycle mock gains a one-shot connect delay so an older add can complete after a newer one; the real-file staging that asserts the W3 residual keys to the write's own read, now the second after the guard. Reverting either fix fails a named test.
1 parent 4eeea09 commit 7a2aaa9

5 files changed

Lines changed: 101 additions & 6 deletions

File tree

packages/opencode/src/altimate/workspace/engine-config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@ export async function persist(name: string, cfg: LocalMcpConfig, configPath?: st
3838
// one write to one file is not atomic, and a disable landing between the read
3939
// and the `write` syscall is still lost. That residual is named on the PR
4040
// rather than papered over; closing it needs write-then-verify.
41+
// The node on disk is the PROJECT file's; intent can also live in the global
42+
// config the project inherits from. A global disable landing after the
43+
// caller's merged read would not be on the text below — and a project pin
44+
// written over it shadows that disable for good, since project wins the
45+
// merge. So the merged view is asked once more, immediately before the
46+
// write. Same window as the write's own read; named, not closed.
47+
let merged: ExistingEntry | null
48+
try {
49+
merged = await existingEntry(name)
50+
} catch (err) {
51+
log.warn("could not confirm intent before writing the engine entry; not writing", { name, err: String(err) })
52+
return "disabled"
53+
}
54+
if (merged?.enabled === false) {
55+
log.info("refusing to write a project entry over a disable in the merged config", { name })
56+
return "disabled"
57+
}
4158
if ((await addMcpToConfig(name, cfg, configPath, { refuseIfDisabled: true })) === null) {
4259
log.info("refusing to write over an entry that is disabled on disk", { name })
4360
return "disabled"

packages/opencode/src/mcp/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -957,9 +957,18 @@ export const layer = Layer.effect(
957957
// altimate_change end
958958
return result.status
959959
}
960-
// altimate_change start — recorded after the bail-early check above, so a
961-
// failed replacement never overwrites the status of a client another
962-
// caller registered while it was coming up.
960+
// altimate_change start — the newer call wins, whichever completes first.
961+
// If another caller registered a client under this key while this one was
962+
// coming up, this result is the OLDER intent arriving late: storing it
963+
// would close their newer client and hand the runtime back to whatever
964+
// this call was asked to start. Close what we made instead, leave theirs
965+
// — client, status and launch record — and answer with what is serving.
966+
if (s.clients[name] !== replacing) {
967+
yield* Effect.tryPromise(() => result.mcpClient!.close()).pipe(Effect.ignore)
968+
return s.status[name] ?? result.status
969+
}
970+
// Recorded after the checks above, so neither a failed nor a superseded
971+
// replacement overwrites the status of a client another caller registered.
963972
s.status[name] = result.status
964973
// altimate_change end
965974

packages/opencode/test/altimate/workspace/config-on-disk.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,30 @@ describe("the write checks the text it is about to modify", () => {
181181
expect(after?.command).toEqual(["datamate", "start-stdio"])
182182
expect(h.added).toHaveLength(0)
183183
})
184+
185+
test("a GLOBAL disable landing after the guard is refused before the project write", async () => {
186+
// The project file holds an enabled node, so the write's own on-disk check
187+
// sees nothing wrong. Intent lives in the global config the project
188+
// inherits from, and a project pin written over a global disable shadows
189+
// it for good (project wins the merge). So persist asks the MERGED view
190+
// once more, immediately before writing.
191+
const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, {
192+
realPersist: true,
193+
})
194+
syncInternals.projectConfigPath = async () => file
195+
syncInternals.projectEntry = async () => (await diskEntry()) ?? null
196+
syncInternals.existingEntry = async () => {
197+
const e = (await diskEntry()) ?? null
198+
h.reads.push(e?.enabled)
199+
// reads: inspection (1), the guard (2), persist's merged re-read (3) —
200+
// the global disable is visible from the third read on, never on disk.
201+
return h.reads.length >= 3 && e ? { ...e, enabled: false } : e
202+
}
203+
const first = await ensure("s1")
204+
expect(first.kind, "wrote a project pin over a global disable").toBe("entry-disabled")
205+
expect((await diskEntry())?.command, "the project file was written").toEqual(["datamate", "start-stdio"])
206+
expect(h.added).toHaveLength(0)
207+
})
184208
})
185209

186210
describe("a disable landing before the revive is honoured", () => {

packages/opencode/test/altimate/workspace/undo-and-teardown.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -508,8 +508,9 @@ describe("the restore refuses on the text it edits", () => {
508508
})
509509
const diskEntry = async () => (await readMcpEntryFromDisk("datamate", file)) as ExistingEntry | undefined
510510

511-
/** After the guard's intent read, config-file readText #1 is now addMcpToConfig's
512-
* ONLY read (persist has no separate check read any more). */
511+
/** After the guard's intent read, config-file readText #1 is persist's merged
512+
* intent re-read (the global-disable check) and #2 is addMcpToConfig's own
513+
* read — the one the write modifies. The window under test is the write's. */
513514
function stage(where: "intent-read-end" | "before-write-read" | "after-write-read") {
514515
const h = install([{ datamate: { status: "connected" } }, { datamate: { status: "connected" } }], () => null, { realPersist: true })
515516
syncInternals.projectConfigPath = async () => file
@@ -532,7 +533,7 @@ describe("the restore refuses on the text it edits", () => {
532533
Filesystem.readText = async (p: string) => {
533534
if (!armed || p !== file || landed) return originalReadText(p)
534535
n += 1
535-
if (n !== 1) return originalReadText(p)
536+
if (n !== 2) return originalReadText(p)
536537
landed = true
537538
if (where === "before-write-read") {
538539
writeFileSync(file, DISABLED_FILE)

packages/opencode/test/mcp/lifecycle.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ function getOrCreateClientState(name?: string): MockClientState {
8989
return state
9090
}
9191

92+
// altimate_change start — a one-shot connect delay, so a test can make an older
93+
// add complete AFTER a newer one for the same key.
94+
let connectDelayOnceMs = 0
95+
// altimate_change end
96+
9297
// Mock transport that succeeds or fails based on connectShouldFail / connectShouldHang
9398
class MockStdioTransport {
9499
stderr: null = null
@@ -99,6 +104,13 @@ class MockStdioTransport {
99104
async start() {
100105
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
101106
if (connectShouldFail) throw new Error(connectError)
107+
// altimate_change start
108+
if (connectDelayOnceMs) {
109+
const delay = connectDelayOnceMs
110+
connectDelayOnceMs = 0
111+
await new Promise<void>((resolve) => setTimeout(resolve, delay))
112+
}
113+
// altimate_change end
102114
}
103115
async close() {
104116
transportCloseCount++
@@ -1326,6 +1338,38 @@ it.instance(
13261338
),
13271339
{ config: { mcp: {} } },
13281340
)
1341+
1342+
it.instance(
1343+
"an older add that completes after a newer one does not replace the newer client",
1344+
() =>
1345+
MCP.Service.use((mcp: MCPNS.Interface) =>
1346+
Effect.gen(function* () {
1347+
// The other half of the same race: both creations SUCCEED, the older
1348+
// one last. Storing it would close the newer client and hand the runtime
1349+
// back to what the older call was asked to start. The newer call wins
1350+
// whichever completes first; the late result is closed, not stored.
1351+
lastCreatedClientName = "racing2"
1352+
getOrCreateClientState("racing2")
1353+
yield* mcp.add("racing2", { type: "local", command: ["echo", "one"] })
1354+
1355+
connectDelayOnceMs = 150
1356+
const slow = yield* Effect.forkChild(mcp.add("racing2", { type: "local", command: ["echo", "two"] }))
1357+
yield* Effect.sleep("20 millis") // the slow add is inside its delayed connect
1358+
yield* mcp.add("racing2", { type: "local", command: ["echo", "three"] })
1359+
const newer = (yield* mcp.clients())["racing2"]
1360+
1361+
const late = yield* Fiber.join(slow) // completes late, and must not win
1362+
expect(localCommand(yield* mcp.spawned("racing2")), "an older add that completed late replaced the newer client").toEqual([
1363+
"echo",
1364+
"three",
1365+
])
1366+
expect((yield* mcp.clients())["racing2"], "the newer client was closed by the late result").toBe(newer)
1367+
// The late call answers with what is serving, not with what it started.
1368+
expect(((late.status as any)["racing2"] ?? late.status).status).toBe("connected")
1369+
}),
1370+
),
1371+
{ config: { mcp: {} } },
1372+
)
13291373
// altimate_change end
13301374

13311375
// altimate_change start — "removed means the runtime forgets it"

0 commit comments

Comments
 (0)