From ad3f0f6c2eea3475dade5309757404f849baac91 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Tue, 5 May 2026 00:33:55 -0700 Subject: [PATCH] fix: handle fs duplicates on write (#2910) --- .github/workflows/docker-image.yaml | 25 +++++++++++++--- src/backend/services/fs/FSService.ts | 43 +++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker-image.yaml b/.github/workflows/docker-image.yaml index 3e1c636e2..8b45eb2f8 100644 --- a/.github/workflows/docker-image.yaml +++ b/.github/workflows/docker-image.yaml @@ -1,12 +1,25 @@ name: Docker Image CI -# Builds only on calver tag pushes: YY.MM or YY.MM.p (all numeric). -# Each build publishes three tags: , latest, main. +# Two ways in: +# - push of a calver tag (YY.MM or YY.MM.p) → publishes , latest, main +# - workflow_dispatch → publishes latest + main from whichever ref the user +# picks in the Actions UI. Used to fast-forward :latest/:main when the +# branch has moved ahead of the most recent release tag. on: push: tags: - '[0-9][0-9].[0-9][0-9]' - '[0-9][0-9].[0-9][0-9].[0-9]*' + workflow_dispatch: + inputs: + push_latest: + description: 'Push :latest tag' + type: boolean + default: true + push_main: + description: 'Push :main tag' + type: boolean + default: true env: REGISTRY: ghcr.io @@ -22,6 +35,7 @@ jobs: steps: - name: Validate calver tag + if: github.event_name == 'push' env: REF_NAME: ${{ github.ref_name }} run: | @@ -51,10 +65,13 @@ jobs: uses: docker/metadata-action@v5 with: images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + # type=ref,event=tag only emits on tag pushes, so manual runs skip + # the calver tag and just publish whichever of latest / main the + # dispatch inputs asked for. On a tag push, both default to true. tags: | type=ref,event=tag - type=raw,value=latest - type=raw,value=main + type=raw,value=latest,enable=${{ github.event_name == 'push' || inputs.push_latest }} + type=raw,value=main,enable=${{ github.event_name == 'push' || inputs.push_main }} - name: Build and push Docker image uses: docker/build-push-action@v5 diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index db5b3ca39..78b1675aa 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -2722,17 +2722,46 @@ export class FSService extends PuterService { } } - const created = await this.stores.fsEntry.createNonFileEntry({ - userId, - parent, - name, - kind: 'directory', - thumbnail: input.thumbnail ?? null, - }); + let created: FSEntry; + try { + created = await this.stores.fsEntry.createNonFileEntry({ + userId, + parent, + name, + kind: 'directory', + thumbnail: input.thumbnail ?? null, + }); + } catch (err) { + // Concurrent mkdir race: another caller inserted the same + // (parent_id, name) between our existence check above and the + // INSERT, tripping the unique key. mkdir on an existing dir is + // documented as idempotent — re-fetch and return the winner if + // it's a directory; otherwise surface the same 409 the pre-INSERT + // check would have produced. + if (!this.#isUniqueViolation(err)) throw err; + const insertedPath = + parent.path === '/' ? `/${name}` : `${parent.path}/${name}`; + const raced = + await this.stores.fsEntry.getEntryByPath(insertedPath); + if (raced?.isDir) return raced; + if (raced) { + throw new HttpError( + 409, + `An entry already exists at ${insertedPath}`, + ); + } + throw err; + } this.#emitFsEvent('fs.create.directory', created); return created; } + #isUniqueViolation(err: unknown): boolean { + if (!(err instanceof Error) || !('code' in err)) return false; + const code = (err as { code?: unknown }).code; + return code === 'ER_DUP_ENTRY' || code === 'SQLITE_CONSTRAINT'; + } + /** * Touch: create an empty file at `path` if missing; otherwise bump * timestamps.