Skip to content

edit and write can silently lose a concurrent successful file update #60509

Open

Description

@HeZ2z

Description

A pending edit can silently overwrite a concurrent successful write to the same file. Both tools report success, but only the edit's stale snapshot remains on disk. This can lose work from another parallel tool call or session. The edit's permission preview also no longer describes the file it actually overwrites.

edit holds the process-wide FileMutation.withLock across reading, permission and writing; write does not acquire that transaction lock. The separate lock inside writeTextPreservingBom does not coordinate with the edit transaction, so it can complete while the edit waits for approval. This differs from #53512 (two apply_patch calls) and #48707 (an older stale-content error message).

Reproduction

On dev commit 82666b6612cdb2ba13a7a26e150b21e897a80373, save this script as /tmp/oc-write-edit-race.ts and run from packages/core:

bun -e "$(cat /tmp/oc-write-edit-race.ts)"
import { Effect, Layer, Deferred, Fiber } from 'effect'
import { AppNodeBuilder } from '@opencode/core/effect/app-node-builder'
import { LayerNode } from '@opencode/util/effect/layer-node'
import { makeLocationNode } from '@opencode/util/effect/app-node'
import { Environment } from '@opencode/core/environment/index'
import { FileMutation } from '@opencode/core/file-mutation'
import { Formatter } from '@opencode/core/formatter'
import { Location } from '@opencode/core/location'
import { FileAccess } from '@opencode/core/file-access'
import { Permission } from '@opencode/core/permission'
import { AbsolutePath } from '@opencode/core/schema'
import { Session } from '@opencode/core/session'
import { Tool } from '@opencode/core/tool'
import { EditTool } from '@opencode/core/tool/plugin/edit'
import { WriteTool } from '@opencode/core/tool/plugin/write'
import { location } from './test/fixture/location'
import { permissionLayer } from './test/lib/permission'
import { toolIdentity, executeTool, registerToolPlugin } from './test/lib/tool'
import { mkdtemp } from 'node:fs/promises'
const dir=await mkdtemp('/tmp/oc-write-edit-race-'); await Bun.write(dir+'/file.txt','old\n')
const deps=[Tool.node,FileAccess.node,FileMutation.node,Environment.node,Formatter.node,Location.node,Permission.node]
const node=makeLocationNode({name:'race-tools',layer:Layer.effectDiscard(Effect.all([registerToolPlugin(EditTool.Plugin),registerToolPlugin(WriteTool.Plugin)])),deps})
const ready=Deferred.makeUnsafe(); const proceed=Deferred.makeUnsafe()
const loc=Layer.succeed(Location.Service,Location.Service.of(location({directory:AbsolutePath.make(dir)})))
const layer=AppNodeBuilder.build(LayerNode.group([Tool.node,FileAccess.node,FileMutation.node,node]),[Location.node.replace(loc),Formatter.node.replace(Layer.mock(Formatter.Service,{file:()=>Effect.succeed(false)})),Permission.node.replace(permissionLayer({assert:(input)=>input.source.id==='edit' ? Deferred.succeed(ready,undefined).pipe(Effect.andThen(Deferred.await(proceed))) : Effect.void}))])
const call=(id,name,input)=>({sessionID:Session.ID.make('ses_race'),...toolIdentity,call:{type:'tool-call',id,name,input}})
await Effect.runPromise(Effect.gen(function*(){const reg=yield* Tool.Service; const edit=yield* executeTool(reg,call('edit','edit',{path:'file.txt',oldString:'old',newString:'edited'})).pipe(Effect.forkChild); yield* Deferred.await(ready); console.log('write',yield* executeTool(reg,call('write','write',{path:'file.txt',content:'other session work\n'}))); console.log('after write',JSON.stringify(yield* Effect.promise(() => Bun.file(dir+'/file.txt').text()))); yield* Deferred.succeed(proceed,undefined); console.log('edit',yield* Fiber.join(edit)); console.log('after edit',JSON.stringify(yield* Effect.promise(() => Bun.file(dir+'/file.txt').text())));}).pipe(Effect.provide(layer)))

The script uses the real registered tools and local filesystem. A permission fixture holds the edit at the approval point; formatting is disabled. Actual output shows write completed, after write is "other session work\n", then edit completed and after edit is "edited\n". Expected: the earlier successful write must not be silently lost; the edit could be serialized with the write or reject a changed file. Both relevant tool source blobs match current upstream dev.

Activity

  1. opencodejr commented on Oct 30, 2026

    @opencodejr

    Thanks for the detailed report and the script. I reproduced this on the latest dev (fa96f42). The files involved are unchanged since the commit you reported.

    With the edit held at its permission prompt, a write to the same file completes and the file contains "other session work\n". Once the edit is approved, it also reports completed, with a diff still showing -old +edited, and the file ends up as "edited\n". The write is silently lost.

    The code matches your analysis. edit holds FileMutation.withLock, which uses the module-level transactionLocks, across reading, the permission prompt and writing. write only goes through writeTextPreservingBom, which uses a separate per-target lock, so the two never wait for each other.

    The script below is a lightly tidied version of yours. Run it from packages/core.

    Related, but not the same problem:

    Reproduction script (race.ts)
    // Run from /workspace/repo/packages/core:  bun run /path/to/race.ts  (copied into packages/core as race.ts)
    import { Effect, Layer, Deferred, Fiber } from "effect"
    import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
    import { LayerNode } from "@opencode/util/effect/layer-node"
    import { makeLocationNode } from "@opencode/util/effect/app-node"
    import { Environment } from "@opencode/core/environment/index"
    import { FileMutation } from "@opencode/core/file-mutation"
    import { Formatter } from "@opencode/core/formatter"
    import { Location } from "@opencode/core/location"
    import { FileAccess } from "@opencode/core/file-access"
    import { Permission } from "@opencode/core/permission"
    import { AbsolutePath } from "@opencode/core/schema"
    import { Session } from "@opencode/core/session"
    import { Tool } from "@opencode/core/tool"
    import { EditTool } from "@opencode/core/tool/plugin/edit"
    import { WriteTool } from "@opencode/core/tool/plugin/write"
    import { location } from "./test/fixture/location"
    import { permissionLayer } from "./test/lib/permission"
    import { toolIdentity, executeTool, registerToolPlugin } from "./test/lib/tool"
    import { mkdtemp } from "node:fs/promises"
    
    const dir = await mkdtemp("/tmp/oc-write-edit-race-")
    await Bun.write(dir + "/file.txt", "old\n")
    const deps = [Tool.node, FileAccess.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node]
    const node = makeLocationNode({
      name: "race-tools",
      layer: Layer.effectDiscard(Effect.all([registerToolPlugin(EditTool.Plugin), registerToolPlugin(WriteTool.Plugin)])),
      deps,
    })
    const ready = Deferred.makeUnsafe<void>()
    const proceed = Deferred.makeUnsafe<void>()
    const loc = Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(dir) })))
    const layer = AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, node]), [
      Location.node.replace(loc),
      Formatter.node.replace(Layer.mock(Formatter.Service, { file: () => Effect.succeed(false) })),
      // Hold the edit at its permission prompt (simulates waiting for user approval)
      Permission.node.replace(
        permissionLayer({
          assert: (input: any) =>
            input.source.id === "edit"
              ? Deferred.succeed(ready, undefined).pipe(Effect.andThen(Deferred.await(proceed)))
              : Effect.void,
        }),
      ),
    ])
    const call = (id: string, name: string, input: unknown) => ({
      sessionID: Session.ID.make("ses_race"),
      ...toolIdentity,
      call: { type: "tool-call", id, name, input },
    })
    const read = () => Effect.promise(() => Bun.file(dir + "/file.txt").text())
    await Effect.runPromise(
      Effect.gen(function* () {
        const reg = yield* Tool.Service
        const edit = yield* executeTool(reg, call("edit", "edit", { path: "file.txt", oldString: "old", newString: "edited" }) as any).pipe(Effect.forkChild)
        yield* Deferred.await(ready)
        console.log("write result:", JSON.stringify(yield* executeTool(reg, call("write", "write", { path: "file.txt", content: "other session work\n" }) as any)))
        console.log("after write:", JSON.stringify(yield* read()))
        yield* Deferred.succeed(proceed, undefined)
        console.log("edit result:", JSON.stringify(yield* Fiber.join(edit)))
        const final = yield* read()
        console.log("after edit:", JSON.stringify(final))
        console.log(final === "edited\n" ? "BUG: successful write was silently overwritten by edit" : "OK: write not lost")
      }).pipe(Effect.provide(layer as any)),
    )

    Run it: bun race.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions