From ca239557d52282812327060a6322fb30a6d54675 Mon Sep 17 00:00:00 2001 From: Mistium <92952823+Mistium@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:59:54 +0000 Subject: [PATCH 1/2] Reserve remix storage before taking the project save lock Remixing asked the Rotur storage ledger for a hold while holding the parent project's save lock, so a slow or unreachable Rotur stalled uploads, history writes and project.json flushes on the parent. The remix now reserves the hold against an unlocked copy of the parent, then reloads the parent under the lock and replays the hold only if the fork's storage still matches. A parent that changed in between releases the hold and tries again, with a 409 after three tries like project updates. Holds are released in a deferred block, and a successful remix keeps and settles its hold as before. --- scripts/e2e_smoke.py | 45 +++- src/git/git-inspection.osl | 13 +- src/projects/project-remix.osl | 218 ++++++++++++++----- src/projects/project-remix.test.osl | 323 ++++++++++++++++++++++++++++ 4 files changed, 543 insertions(+), 56 deletions(-) create mode 100644 src/projects/project-remix.test.osl diff --git a/scripts/e2e_smoke.py b/scripts/e2e_smoke.py index 09a14d4..37ad9e5 100644 --- a/scripts/e2e_smoke.py +++ b/scripts/e2e_smoke.py @@ -14,6 +14,7 @@ import threading import time import urllib.error +import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import uuid @@ -29,12 +30,13 @@ "monitors": [], "extensions": [], "meta": {"semver": "3.0.0", "vm": "0.2.0", "agent": "smoke"}, } -USERS = {"sam": "u_sam", "alice": "u_alice", "bob": "u_bob", "dee": "u_dee", "mist": "u_mist"} +USERS = {"sam": "u_sam", "alice": "u_alice", "bob": "u_bob", "dee": "u_dee", "mist": "u_mist", "lena": "u_lena"} FULL_STORAGE = {"u_dee"} GONE_USERS = {"gone_user"} BUSY_USERS = {"busy_user"} BANNED_USERS = {"banned_user"} SLOW_USERS = {"slow_user": "u_slow"} +SLOW_LEDGER_USERS = {"u_lena"} ROTUR_DELAY = 4 EXPECTED_ERRORS = {"patch team unknown user", "patch transfer unknown", "patch team rotur unavailable", "patch transfer rotur unavailable", "upload unsaved first save of the day", "upload extension store failure", "upload extension store failure with history", "upload json store failure", "upload metadata save failure"} SCOPED_SESSIONS = {"sam_editor": ("sam", "editor")} @@ -60,6 +62,9 @@ def default_sb3(): class FakeRotur(BaseHTTPRequestHandler): + slow_ledger_reads = 0 + slow_ledger_guard = threading.Lock() + def answer(self, status, payload): body = json.dumps(payload).encode() self.send_response(status) @@ -70,6 +75,12 @@ def answer(self, status, payload): def do_GET(self): if self.path.startswith("/v2/storage-ledger/objects"): + if urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("user_id", [""])[0] in SLOW_LEDGER_USERS: + with FakeRotur.slow_ledger_guard: + FakeRotur.slow_ledger_reads += 1 + time.sleep(ROTUR_DELAY) + with FakeRotur.slow_ledger_guard: + FakeRotur.slow_ledger_reads -= 1 self.answer(200, {"objects": []}) return if self.path.startswith("/v2/storage-ledger/"): @@ -802,6 +813,37 @@ def timed(name, send): }}) +def slow_ledger_flow(server, step, steps, ids): + status, created = step("slow ledger create", "POST", "/v1/projects", "sam", {"title": "Slow ledger smoke"}) + project_id = str(created.get("id", "")) if isinstance(created, dict) else "" + ids[project_id] = "" + step("slow ledger upload", "POST", "/v1/projects//upload", "sam", form=({}, {"project": sb3_archive(project_with_costumes([]))})) + step("slow ledger share", "POST", "/v1/projects//visibility", "sam", {"visibility": "public"}) + step("accept agreement lena", "POST", "/v1/agreement/accept", "lena", {}) + results = {} + waited = {} + + def remix(): + results["remix"] = server.call("POST", "/v1/projects/%s/remix" % project_id, "lena", {}, timeout=60)[0] + + remixing = threading.Thread(target=remix) + remixing.start() + time.sleep(0.5) + asset_name, asset_content = svg_asset("slow ledger") + started = time.time() + results["upload"] = server.upload(project_id, "sam", sb3_archive(project_with_costumes([asset_name]), {asset_name: asset_content}))[0] + waited["upload"] = time.time() - started >= ROTUR_DELAY / 2 + remixing.join() + time.sleep(0.5) + for _ in range(ROTUR_DELAY * 20): + if not FakeRotur.slow_ledger_reads: + break + time.sleep(0.1) + steps.append({"step": "upload during a remix with a slow storage ledger", "status": 0, "body": { + "statuses": results, "waitedForLedger": waited, + }}) + + def locks_flow(server, step, steps, ids): status, created = step("locks create", "POST", "/v1/projects", "sam", {"title": "Locks smoke"}) ids[str(created.get("id", "")) if isinstance(created, dict) else ""] = "" @@ -977,6 +1019,7 @@ def step(name, method, path, who=None, body=None, upload=None, headers=(), form= update_flow(server, step, ids) concurrent_flow(server, step, steps, ids) slow_rotur_flow(server, step, steps, ids) + slow_ledger_flow(server, step, steps, ids) locks_flow(server, step, steps, ids) time.sleep(1.5) for real, alias in list(ids.items()): diff --git a/src/git/git-inspection.osl b/src/git/git-inspection.osl index de73d81..ffd5266 100644 --- a/src/git/git-inspection.osl +++ b/src/git/git-inspection.osl @@ -2,7 +2,8 @@ export { cachedProjectWorkspacePath, projectRecordsCommit, projectWorkspaceFingerprint, - validGitObjectId + validGitObjectId, + writeForkHistoryArchive } auto gitInspectionPruneCache = cache.new({capacity: 4, ttl: 300000}) @@ -72,6 +73,16 @@ def cachedProjectWorkspacePath(object project) string ( return cachedPath ) +def writeForkHistoryArchive(object project, string branch, string forkId, string outputPath) object ( + string forkJson = gitinspection.ForkHistoryArchiveJSON(cachedProjectWorkspacePath(project), outputPath, branch, forkId, project.id.toStr()) + auto parsed = json.parse(forkJson, {type: "object"}) + if parsed.isErr() or parsed.unwrap().assert(object).ok != true ( + void fs.remove(outputPath) + return {ok: false} + ) + return {ok: true, manifest: parsed.unwrap().assert(object).manifest.assert(object)} +) + def validGitObjectId(string oid) boolean ( return regex.match("^[a-f0-9]{40}$", oid) ) diff --git a/src/projects/project-remix.osl b/src/projects/project-remix.osl index 17946fb..69fb8ef 100644 --- a/src/projects/project-remix.osl +++ b/src/projects/project-remix.osl @@ -118,29 +118,101 @@ def handleGetRemixTree(*serve.Context c) ( c.json(200, {ok: true, root: rootId, nodes: nodes}) ) +type ForkRequest ( + string id = "" + string username = "" + string userId = "" + object benefits = {} + string title = "" + string branch = "" + boolean enforceLimits = true +) + +type PreparedFork ( + object failure = {} + object parent = {} + byte[] history = [] + object manifest = {} + object hold = {} + string holdKey = "" +) + +type ForkOutcome ( + Refusal refusal = Refusal() + object forked = {} + string keptHoldKey = "" +) + def handleRemixProject(*serve.Context c) ( object benefits = mistwarpBenefitsForUserId(contextString(c, "userId")) + object body = requestJSON(c) + string forkId = generateId() + for attempt 3 ( + if !remixProjectOnce(c, body, benefits, forkId) ( + return + ) + ) + c.json(409, {ok: false, code: "project_changed", error: "The project changed while it was being remixed. Try again."}) +) + +def remixProjectOnce(*serve.Context c, object body, object benefits, string forkId) boolean ( + UpdateLookups lookups = UpdateLookups() + ForkOutcome outcome = ForkOutcome() + defer ( + releaseStorageHolds(lookups, outcome.keptHoldKey) + ) + holdRemixStorage(c, body, benefits, forkId, lookups) + lookups.replay = true runLocked(projectSaveLock(c.param("id")), def() -> ( - handleRemixProjectLocked(c, benefits) + remixProjectLocked(c, body, benefits, forkId, lookups, outcome) )) + if lookups.missed ( + return true + ) + finishRemix(c, outcome) + return false ) -def handleRemixProjectLocked(*serve.Context c, object benefits) ( +def holdRemixStorage(*serve.Context c, object body, object benefits, string forkId, UpdateLookups lookups) ( + if !usesRoturStorage(contextString(c, "userId")) ( + return + ) auto res = loadProject(c.param("id")) if !res.ok ( - c.notFound("project not found") return ) object parent = res.project.assert(object) - if !canViewProject(parent, c) ( - c.notFound("project not found") + if isRefused(remixRefusal(c, parent)) ( return ) - if !canRemixProject(parent, c) ( - c.forbidden("this project cannot be remixed") + void prepareFork(parent, remixForkRequest(c, body, benefits, forkId, parent), lookups) +) + +def remixProjectLocked(*serve.Context c, object body, object benefits, string forkId, UpdateLookups lookups, ForkOutcome outcome) ( + auto res = loadProject(c.param("id")) + if !res.ok ( + outcome.refusal = refuse(404, "project not found") return ) - object body = requestJSON(c) + object parent = res.project.assert(object) + outcome.refusal = remixRefusal(c, parent) + if isRefused(outcome.refusal) ( + return + ) + forkProjectWith(parent, remixForkRequest(c, body, benefits, forkId, parent), lookups, outcome) +) + +def remixRefusal(*serve.Context c, object parent) Refusal ( + if !canViewProject(parent, c) ( + return refuse(404, "project not found") + ) + if !canRemixProject(parent, c) ( + return refuse(403, "this project cannot be remixed") + ) + return noRefusal() +) + +def remixForkRequest(*serve.Context c, object body, object benefits, string forkId, object parent) ForkRequest ( string forkTitle = parent.title.toStr() ++ " fork" if body.contains("title") and body.title != null and body.title.toStr().strip() != "" ( forkTitle = body.title.toStr().strip().trim(1, 100) @@ -149,12 +221,18 @@ def handleRemixProjectLocked(*serve.Context c, object benefits) ( if body.contains("branch") and body.branch != null ( forkBranch = body.branch.toStr().strip() ) - object forked = forkProjectFor(parent, contextString(c, "username"), contextString(c, "userId"), benefits, forkTitle, forkBranch, !contextBool(c, "isAdmin")) - if forked.ok != true ( - forkFailureResponse(c, forked) + return ForkRequest{id: forkId, username: contextString(c, "username"), userId: contextString(c, "userId"), benefits: benefits, title: forkTitle, branch: forkBranch, enforceLimits: !contextBool(c, "isAdmin")} +) + +def finishRemix(*serve.Context c, ForkOutcome outcome) ( + if refused(c, outcome.refusal) ( + return + ) + if outcome.forked.ok != true ( + forkFailureResponse(c, outcome.forked) return ) - object project = forked.project.assert(object) + object project = outcome.forked.project.assert(object) c.json(201, { ok: true, id: project.id, @@ -177,78 +255,111 @@ def forkFailureResponse(*serve.Context c, object forked) ( ) def forkProjectFor(object source, string username, string userId, object benefits, string forkTitle, string requestedBranch, boolean enforceLimits) object ( + UpdateLookups lookups = UpdateLookups() + ForkOutcome outcome = ForkOutcome() + defer ( + releaseStorageHolds(lookups, outcome.keptHoldKey) + ) + ForkRequest request = ForkRequest{id: generateId(), username: username, userId: userId, benefits: benefits, title: forkTitle, branch: requestedBranch, enforceLimits: enforceLimits} + forkProjectWith(source, request, lookups, outcome) + return outcome.forked +) + +def forkProjectWith(object source, ForkRequest request, UpdateLookups lookups, ForkOutcome outcome) ( + PreparedFork prepared = prepareFork(source, request, lookups) + if lookups.missed or prepared.failure.len > 0 ( + outcome.forked = prepared.failure + return + ) + outcome.forked = createFork(prepared, request, outcome) +) + +def forkRefused(number status, string code, string message) PreparedFork ( + return PreparedFork{failure: {ok: false, status: status, code: code, error: message}} +) + +def forkStorageChanges(object parent, string forkId, number historyBytes) object ( + // The fork shares the original's project.json and assets, and brings + // its own history. + object forkSummary = projectStorageSummary(parent, loadAssetsIndex().assets.assertElse(object, {})) + object forkHistoryBlob = {} + forkHistoryBlob["incoming:" ++ forkId] = historyBytes + forkSummary.history = forkHistoryBlob + object saving = {} + saving[forkId] = forkSummary + return {projects: saving} +) + +def prepareFork(object source, ForkRequest request, UpdateLookups lookups) PreparedFork ( object parent = source - number projectLimit = benefits.maxProjects.toNum() - if enforceLimits and ownedProjectSlotCount(userId, username) >= projectLimit ( - return {ok: false, status: 413, code: "project_limit", error: "project limit reached; delete a trashed project permanently or upgrade your plan"} + number projectLimit = request.benefits.maxProjects.toNum() + if request.enforceLimits and ownedProjectSlotCount(request.userId, request.username) >= projectLimit ( + return forkRefused(413, "project_limit", "project limit reached; delete a trashed project permanently or upgrade your plan") ) string forkBranch = "main" if parent.contains("gitBranch") and parent.gitBranch != null and parent.gitBranch.toStr() != "" ( forkBranch = parent.gitBranch.toStr() ) - if requestedBranch != "" ( - forkBranch = requestedBranch + if request.branch != "" ( + forkBranch = request.branch ) if forkBranch.len > 100 or forkBranch.contains(" ") or forkBranch.contains("\\") or forkBranch.contains("..") or forkBranch.startsWith("/") ( - return {ok: false, status: 400, code: "invalid_branch", error: "invalid branch name"} + return forkRefused(400, "invalid_branch", "invalid branch name") ) object jsonSnapshot = ensureProjectJsonSnapshot(parent) if !jsonSnapshot.ok ( - return {ok: false, status: 500, code: "copy_failed", error: "could not copy the original project, try again"} + return forkRefused(500, "copy_failed", "could not copy the original project, try again") ) parent = jsonSnapshot.project.assert(object) object workspaceSnapshots = ensureProjectWorkspaceSnapshots(parent) if !workspaceSnapshots.ok ( - return {ok: false, status: 500, code: "history_failed", error: "could not prepare the original project history, try again"} + return forkRefused(500, "history_failed", "could not prepare the original project history, try again") ) parent = workspaceSnapshots.project.assert(object) if !projectHasWorkspace(parent) ( - return {ok: false, status: 409, code: "history_unavailable", error: "prepare the original project's history before remixing"} + return forkRefused(409, "history_unavailable", "prepare the original project's history before remixing") ) - string id = generateId() - string sourcePath = cachedProjectWorkspacePath(parent) - string forkPath = tmpDir ++ id ++ ".fork.mwp" - string parentId = parent.id.toStr() - string forkJson = gitinspection.ForkHistoryArchiveJSON(sourcePath, forkPath, forkBranch, id, parentId) - auto parsedFork = json.parse(forkJson, {type: "object"}) - if parsedFork.isErr() or parsedFork.unwrap().assert(object).ok != true ( - void fs.remove(forkPath) - return {ok: false, status: 409, code: "history_unavailable", error: "requested branch history is unavailable"} + string forkPath = tmpDir ++ request.id ++ ".fork.mwp" + object archived = writeForkHistoryArchive(parent, forkBranch, request.id, forkPath) + if archived.ok != true ( + return forkRefused(409, "history_unavailable", "requested branch history is unavailable") ) - object forkHistory = parsedFork.unwrap().assert(object).manifest.assert(object) + object forkHistory = archived.manifest.assert(object) byte[] forkBody = fs.readFileBytes(forkPath).unwrapOr("".toBytes()) void fs.remove(forkPath) + PreparedFork prepared = PreparedFork{parent: parent, history: forkBody, manifest: forkHistory, hold: noStorageHold()} number forkBytes = projectSizeBytes(parent) + forkBody.len - object hold = noStorageHold() - if usesRoturStorage(userId) ( - // The fork shares the original's project.json and assets, and brings - // its own history. - object forkSummary = projectStorageSummary(parent, loadAssetsIndex().assets.assertElse(object, {})) - object forkHistoryBlob = {} - forkHistoryBlob["incoming:" ++ id] = forkBody.len - forkSummary.history = forkHistoryBlob - object saving = {} - saving[id] = forkSummary - hold = storageHold(userId, {projects: saving}, enforceLimits) - if hold.ok != true ( - return {ok: false, status: 413, code: "storage_full", error: "storage full", refusal: hold.refusal} + if usesRoturStorage(request.userId) ( + object changes = forkStorageChanges(parent, request.id, forkBody.len) + prepared.holdKey = storageHoldKey(request.userId, changes, request.enforceLimits) + prepared.hold = lookupStorageHold(lookups, request.userId, changes, request.enforceLimits) + if prepared.hold.ok != true ( + prepared.failure = {ok: false, status: 413, code: "storage_full", error: "storage full", refusal: prepared.hold.refusal} ) - ) else if enforceLimits and storageAllowance(benefits.storageBytes.toNum(), ownerStorageBytes(username), 0) < forkBytes ( - return {ok: false, status: 413, code: "storage_full", error: "storage full", username: username, bytes: forkBytes} + ) else if request.enforceLimits and storageAllowance(request.benefits.storageBytes.toNum(), ownerStorageBytes(request.username), 0) < forkBytes ( + prepared.failure = {ok: false, status: 413, code: "storage_full", error: "storage full", username: request.username, bytes: forkBytes} ) + return prepared +) + +def createFork(PreparedFork prepared, ForkRequest request, ForkOutcome outcome) object ( + object parent = prepared.parent + string id = request.id + string parentId = parent.id.toStr() + byte[] forkBody = prepared.history + object forkHistory = prepared.manifest string forkKey = projectWorkspaceSnapshotKey(contentHash(forkBody)) if !stageR2Blob(forkKey, forkBody, "application/x-mistwarp-project", "") ( - storageHoldRelease(userId, hold) return {ok: false, status: 500, code: "store_failed", error: "could not store fork history"} ) object project = { id: id, - owner: username, - ownerId: userId, - title: forkTitle, + owner: request.username, + ownerId: request.userId, + title: request.title, description: "", instructions: "", repo: null, @@ -295,16 +406,15 @@ def forkProjectFor(object source, string username, string userId, object benefit ) if !storeProjectExtensions(id, [], storedProjectExtensions(project), {}) ( - storageHoldRelease(userId, hold) return {ok: false, status: 500, code: "extensions_failed", error: "could not index project extensions"} ) if !saveProject(project) ( - storageHoldRelease(userId, hold) return {ok: false, status: 500, code: "save_failed", error: "could not save fork"} ) + outcome.keptHoldKey = prepared.holdKey upsertIndexEntry(project) - if usesRoturStorage(userId) ( - thread.new(storageSettle, userId, hold) + if usesRoturStorage(request.userId) ( + thread.new(storageSettle, request.userId, prepared.hold) ) return {ok: true, project: project} ) diff --git a/src/projects/project-remix.test.osl b/src/projects/project-remix.test.osl new file mode 100644 index 0000000..ef5f09e --- /dev/null +++ b/src/projects/project-remix.test.osl @@ -0,0 +1,323 @@ +import "std:testing" +import "std:db" +import "std:fs" +import "std:json" +import "std:requests" +import "std:serve" +import "std:sync" +import "std:thread" +import "./project-refusal.osl" +import "./project-request.osl" +import "./project-lock.osl" +import "./project-update-lookups.osl" + +string work = "/tmp/mistwarp-remix-test-" ++ timestamp.toStr() +void fs.mkdir(work, {recursive: true}) +string tmpDir = work ++ "/" +auto testDb = db.open(work ++ "/community.db") +*db.Collection projectSearchDocs = testDb.collection("project_search") + +def lockIsFree(string lockName) boolean ( + boolean free = thread.new(def() -> ( + boolean taken = sync.tryLock(lockName) + if taken ( + sync.unlock(lockName) + ) + return taken + )).wait() + return free +) + +number parentBytes = 100 +number loads = 0 +number growOnLoad = 0 +boolean growEveryLoad = false +boolean roturStorage = true +boolean storageFull = false +boolean saveWorks = true +boolean failing = false +number holdsAsked = 0 +number ids = 0 +array holdLockFree = [] +array released = [] +array settled = [] + +def resetScenario() ( + parentBytes = 100 + loads = 0 + growOnLoad = 0 + growEveryLoad = false + roturStorage = true + storageFull = false + saveWorks = true + failing = false + holdsAsked = 0 + holdLockFree = [] + released = [] + settled = [] +) + +def parentProject() object ( + return {id: "p1", title: "Parent", jsonKey: "project-json/parent", storedJsonBytes: parentBytes, assets: [], gitBranch: "main"} +) + +def loadProject(string id) object ( + loads += 1 + if growEveryLoad or loads == growOnLoad ( + parentBytes += 1 + ) + if id != "p1" ( + return {ok: false} + ) + return {ok: true, project: parentProject()} +) + +def contextString(*serve.Context c, string key) string ( + if key == "userId" ( + return "u_ana" + ) + if key == "username" ( + return "ana" + ) + return "" +) +def contextBool(*serve.Context c, string key) boolean ( + return false +) +def requestJSON(*serve.Context c) object ( + return {} +) +def canViewProject(object project,*serve.Context c) boolean ( + return true +) +def canRemixProject(object project,*serve.Context c) boolean ( + return true +) +def canEditProject(object project,*serve.Context c) boolean ( + return true +) +def projectRole(object project, string username) string ( + return "" +) +def projectHiddenFrom(object project,*serve.Context c) boolean ( + return false +) +def projectsVisibleTo(array entries,*serve.Context c) array ( + return entries +) +def projectVisibility(object project) string ( + return "public" +) +def indexEntryFromProject(object project) object ( + return project +) +def mistwarpBenefitsForUserId(string userId) object ( + return {maxProjects: 100, storageBytes: 1000000} +) +def mistwarpBenefitsForTier(string tier) object ( + return {maxProjects: 100} +) +def discoverUserIdentityOutcome(string username) object ( + return {id: "", outcome: "unavailable"} +) +def userIdForUsername(string username) string ( + return "" +) +def vanityEligibility(object project) string ( + return "unknown" +) +def canManageGroupTag(string tag,*serve.Context c) boolean ( + return false +) +def groupTagOf(object value) string ( + return "" +) +def generateId() string ( + ids += 1 + return "fork" ++ ids.toStr() +) +def randomString(number length) string ( + return "r" +) +def ownedProjectSlotCount(string userId, string username) number ( + return 0 +) +def ensureProjectJsonSnapshot(object project) object ( + return {ok: true, project: project} +) +def ensureProjectWorkspaceSnapshots(object project) object ( + return {ok: true, project: project} +) +def projectHasWorkspace(object project) boolean ( + return true +) +def writeForkHistoryArchive(object project, string branch, string forkId, string outputPath) object ( + void fs.writeFile(outputPath, "history") + return {ok: true, manifest: {head: "h", branch: branch, commits: [], graph: {}}} +) +def projectSizeBytes(object project) number ( + return 10 +) +def loadAssetsIndex() object ( + return {assets: {}} +) +def projectStorageSummary(object project, object assetSizes) object ( + object jsonBlobs = {} + jsonBlobs[project.jsonKey.toStr()] = project.storedJsonBytes.toNum() + return {json: jsonBlobs, history: {}, assets: []} +) +def storageAllowance(number limit, number used, number currentBytes) number ( + return limit - used +) +def ownerStorageBytes(string username) number ( + return 0 +) +def usesRoturStorage(string userId) boolean ( + return roturStorage and userId != "" +) +def noStorageHold() object ( + return {ok: true, reservations: []} +) +def storageHold(string userId, object changes, boolean enforce) object ( + holdsAsked += 1 + holdLockFree.append(lockIsFree("project-history-p1")) + if storageFull ( + return {ok: false, refusal: {needed: 5}} + ) + return {ok: true, reservations: [{id: "hold" ++ holdsAsked.toStr()}]} +) +def storageHoldRelease(string userId, object hold) ( + array reservations = (hold.reservations ?? []).assertElse(array, []) + for i reservations.len ( + released.append(reservations[i].assertElse(object, {}).id.toStr()) + ) +) +def storageSettle(string userId, object hold) ( + array reservations = (hold.reservations ?? []).assertElse(array, []) + for i reservations.len ( + settled.append(reservations[i].assertElse(object, {}).id.toStr()) + ) +) +def projectWorkspaceSnapshotKey(string hash) string ( + return "project-workspaces/" ++ hash +) +def contentHash(any body) string ( + return "hash" +) +def stageR2Blob(string key, any body, string contentType, string contentEncoding) boolean ( + if failing ( + throw "IOError: staging failed" + ) + return true +) +def storedProjectExtensions(object project) array ( + return [] +) +def storeProjectExtensions(string projectId, array previousExtensions, array extensions, object sources) boolean ( + return true +) +def saveProject(object project) boolean ( + return saveWorks +) +def upsertIndexEntry(object project) boolean ( + return true +) +def projectJsonUrlFor(object project) string ( + return "" +) +def assetsBaseUrl() string ( + return "" +) +def storageDetails(string username) object ( + return {remaining: 0} +) +def storageFullResponse(*serve.Context c, object details, number neededBytes) ( + c.json(413, {ok: false, code: "storage_full"}) +) +def roturStorageFullResponse(*serve.Context c, object refusal) ( + c.json(413, {ok: false, code: "storage_full", needed: refusal.needed}) +) + +import "./project-remix.osl" + +*serve.Router app = serve.new() +app.POST("/projects/:id/remix", handleRemixProject) +app.GET("/ready", def(*serve.Context c) -> ( + c.json(200, {ok: true}) +)) +def runApp() ( + app.run(":18362") +) +thread.new(runApp) +string base = "http://127.0.0.1:18362" +for i 50 ( + if requests.get(base ++ "/ready").isOk() ( + break + ) + wait 0.1 +) + +def remix(string id) object ( + object resp = requests.post(base ++ "/projects/" ++ id ++ "/remix", {body: "{}"}).unwrapOr({status: 0, body: "{}"}) + object body = json.parse((resp.body ?? "{}").toStr(), {type: "object"}).unwrapOr({}) + wait 0.2 + return {status: resp.status.toNum(), body: body} +) + +object made = remix("p1") +testing.assert(made.status == 201 and made.body.id == "fork1", "a remix of an unchanged project is created") +testing.assert(holdsAsked == 1 and holdLockFree == [true], "the storage hold is taken once, with the save lock free") +testing.assert(released.len == 0 and settled == ["hold1"], "a successful remix keeps its hold and settles it") + +resetScenario() +growOnLoad = 2 +made = remix("p1") +testing.assert(made.status == 201 and holdsAsked == 2 and holdLockFree == [true, true], "a project that changed before the lock is held again on the next try") +testing.assert(released == ["hold1"] and settled == ["hold2"], "the stale hold is released and the fresh one kept") + +resetScenario() +growEveryLoad = true +made = remix("p1") +testing.assert(made.status == 409 and made.body.code == "project_changed", "a project that keeps changing is refused after three tries") +testing.assert(holdsAsked == 3 and released == ["hold1", "hold2", "hold3"] and settled.len == 0, "every try's hold is released") + +resetScenario() +storageFull = true +made = remix("p1") +testing.assert(made.status == 413 and made.body.code == "storage_full" and made.body.needed == 5, "full Rotur storage refuses the remix") +testing.assert(holdsAsked == 1 and settled.len == 0, "the refusal is not asked for again under the lock") + +resetScenario() +saveWorks = false +made = remix("p1") +testing.assert(made.status == 500 and made.body.code == "save_failed", "a fork that can't be saved fails") +testing.assert(released == ["hold1"] and settled.len == 0, "a failed save releases the hold") + +resetScenario() +failing = true +made = remix("p1") +testing.assert(made.status >= 500 and released == ["hold1"] and settled.len == 0, "a remix that throws releases its hold") +testing.assert(lockIsFree("project-history-p1"), "a remix that throws releases the save lock") + +resetScenario() +roturStorage = false +made = remix("p1") +testing.assert(made.status == 201 and holdsAsked == 0 and loads == 1, "accounts outside Rotur storage remix in one pass without a hold") + +resetScenario() +made = remix("missing") +testing.assert(made.status == 404 and holdsAsked == 0, "a missing project is not found") + +resetScenario() +object copied = forkProjectFor(parentProject(), "teacher", "u_teacher", mistwarpBenefitsForUserId("u_teacher"), "Copy", "", false) +wait 0.2 +testing.assert(copied.ok == true and copied.project.title == "Copy" and copied.project.owner == "teacher", "a classroom copy is forked directly") +testing.assert(holdsAsked == 1 and released.len == 0 and settled == ["hold1"], "a classroom copy keeps and settles its hold") + +resetScenario() +saveWorks = false +copied = forkProjectFor(parentProject(), "teacher", "u_teacher", mistwarpBenefitsForUserId("u_teacher"), "Copy", "", false) +testing.assert(copied.code == "save_failed" and released == ["hold1"], "a classroom copy that can't be saved releases its hold") + +testDb.close() +void fs.remove(work, {force: true}) From 03d4495c8a75cfa9aae93a3b004a406ec49a8912 Mon Sep 17 00:00:00 2001 From: Mistium <92952823+Mistium@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:12:52 +0000 Subject: [PATCH 2/2] Test a parent made private or closed to remixes between passes, and drop a moved comment --- src/projects/project-remix.osl | 2 -- src/projects/project-remix.test.osl | 25 ++++++++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/projects/project-remix.osl b/src/projects/project-remix.osl index 69fb8ef..4ce7eae 100644 --- a/src/projects/project-remix.osl +++ b/src/projects/project-remix.osl @@ -279,8 +279,6 @@ def forkRefused(number status, string code, string message) PreparedFork ( ) def forkStorageChanges(object parent, string forkId, number historyBytes) object ( - // The fork shares the original's project.json and assets, and brings - // its own history. object forkSummary = projectStorageSummary(parent, loadAssetsIndex().assets.assertElse(object, {})) object forkHistoryBlob = {} forkHistoryBlob["incoming:" ++ forkId] = historyBytes diff --git a/src/projects/project-remix.test.osl b/src/projects/project-remix.test.osl index ef5f09e..2e42a8f 100644 --- a/src/projects/project-remix.test.osl +++ b/src/projects/project-remix.test.osl @@ -31,6 +31,8 @@ def lockIsFree(string lockName) boolean ( number parentBytes = 100 number loads = 0 number growOnLoad = 0 +number revokeViewOnLoad = 0 +number revokeRemixOnLoad = 0 boolean growEveryLoad = false boolean roturStorage = true boolean storageFull = false @@ -46,6 +48,8 @@ def resetScenario() ( parentBytes = 100 loads = 0 growOnLoad = 0 + revokeViewOnLoad = 0 + revokeRemixOnLoad = 0 growEveryLoad = false roturStorage = true storageFull = false @@ -69,7 +73,10 @@ def loadProject(string id) object ( if id != "p1" ( return {ok: false} ) - return {ok: true, project: parentProject()} + object project = parentProject() + project.viewRevoked = revokeViewOnLoad > 0 and loads >= revokeViewOnLoad + project.remixRevoked = revokeRemixOnLoad > 0 and loads >= revokeRemixOnLoad + return {ok: true, project: project} ) def contextString(*serve.Context c, string key) string ( @@ -88,10 +95,10 @@ def requestJSON(*serve.Context c) object ( return {} ) def canViewProject(object project,*serve.Context c) boolean ( - return true + return project.viewRevoked != true ) def canRemixProject(object project,*serve.Context c) boolean ( - return true + return project.remixRevoked != true ) def canEditProject(object project,*serve.Context c) boolean ( return true @@ -304,6 +311,18 @@ roturStorage = false made = remix("p1") testing.assert(made.status == 201 and holdsAsked == 0 and loads == 1, "accounts outside Rotur storage remix in one pass without a hold") +resetScenario() +revokeViewOnLoad = 2 +made = remix("p1") +testing.assert(made.status == 404 and holdsAsked == 1, "a parent made private before the lock is not remixed") +testing.assert(released == ["hold1"] and settled.len == 0, "the hold for a parent made private is released") + +resetScenario() +revokeRemixOnLoad = 2 +made = remix("p1") +testing.assert(made.status == 403 and holdsAsked == 1, "a parent closed to remixes before the lock is not remixed") +testing.assert(released == ["hold1"] and settled.len == 0, "the hold for a parent closed to remixes is released") + resetScenario() made = remix("missing") testing.assert(made.status == 404 and holdsAsked == 0, "a missing project is not found")