Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(Project): prevent potential data corruption in saveToCloud and saveToLocalCache #777

Merged
merged 1 commit into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion spx-gui/src/components/project/ProjectCreateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ const handleSubmit = useMessageHandle(
project.addSprite(sprite)
await sprite.autoFit()
// upload project content & call API addProject, TODO: maybe this should be extracted to `@/models`?
const files = project.export()[1]
const [, files] = await project.export()
const { fileCollection } = await saveFiles(files)
const projectData = await addProject({
name: form.value.name,
Expand Down
2 changes: 1 addition & 1 deletion spx-gui/src/models/animation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe('Animation', () => {
const project = makeProject()
project.sprites[0].animations[0].setSound(project.sounds[0].name)

const [metadata, files] = project.export()
const [metadata, files] = await project.export()
const delayedFiles: Files = Object.fromEntries(
Object.entries(files).map(([path, file]) => [path, delayFile(file!, 50)])
)
Expand Down
9 changes: 3 additions & 6 deletions spx-gui/src/models/project/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import { shallowReactive } from 'vue'
import type { LocaleMessage } from '@/utils/i18n'
import Mutex from '@/utils/mutex'
import type { Files } from '../common/file'
import type { Project } from '.'

Expand All @@ -27,8 +26,6 @@ export type State = {
}

export class History {
private mutex = new Mutex()

constructor(
private project: Project,
/**
Expand Down Expand Up @@ -71,7 +68,7 @@ export class History {
}

redo() {
return this.mutex.runExclusive(() => this.goto(this.index + 1))
return this.project.historyMutex.runExclusive(() => this.goto(this.index + 1))
}

getUndoAction() {
Expand All @@ -80,11 +77,11 @@ export class History {
}

undo() {
return this.mutex.runExclusive(() => this.goto(this.index - 1))
return this.project.historyMutex.runExclusive(() => this.goto(this.index - 1))
}

doAction<T>(action: Action, fn: () => T | Promise<T>): Promise<T> {
return this.mutex.runExclusive(async () => {
return this.project.historyMutex.runExclusive(async () => {
// history after current state (for redo) will be discarded on any action
this.states.splice(this.index)

Expand Down
14 changes: 13 additions & 1 deletion spx-gui/src/models/project/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { Sprite } from '../sprite'
import { Animation } from '../animation'
import { Sound } from '../sound'
Expand Down Expand Up @@ -124,4 +124,16 @@ describe('Project', () => {
project.removeSprite('Sprite2')
expect(project.selected).toBeNull()
})

it('should throw an error when saving a disposed project', async () => {
const project = makeProject()
const saveToLocalCacheMethod = vi.spyOn(project, 'saveToLocalCache' as any)

project.dispose()

await expect(project.saveToCloud()).rejects.toThrow('disposed')

await expect((project as any).saveToLocalCache('key')).rejects.toThrow('disposed')
expect(saveToLocalCacheMethod).toHaveBeenCalledWith('key')
})
})
52 changes: 31 additions & 21 deletions spx-gui/src/models/project/index.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这次解决的问题方便通过构造测试用例复现吗,方便的话加下用例?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

如果要构造测试用例来复现,那可能得 mock Disposable,因为 saveToCloud/saveToLocalCache 里直接依赖了它的 isDisposed。我在想有没有必要,因为问题的根源是

  constructor() {
    super()
    const reactiveThis = reactive(this) as this
    this.history = new History(reactiveThis)
    this.zorder = []
    this.stage = new Stage()
    this.sprites = []
    this.sounds = []
    this.addDisposer(() => {
      this.sprites.splice(0).forEach((s) => s.dispose())
      this.sounds.splice(0).forEach((s) => s.dispose())
    })
    return reactiveThis
  }

这里面的 sprites 和 sounds 在 dispose 时被清空了,而自动保存在 dispose 后才执行。

我们应该只用在测试中确保 isDisposedtrue 时,saveToCloud 或者 saveToLocalCache 会抛出异常就行了吧?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我们应该只用在测试中确保 isDisposedtrue 时,saveToCloud 或者 saveToLocalCache 会抛出异常就行了吧?

嗯可以的

Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Sprite } from '../sprite'
import { Sound } from '../sound'
import type { RawWidgetConfig } from '../widget'
import { History } from './history'
import Mutex from '@/utils/mutex'

export type { Action } from './history'

Expand Down Expand Up @@ -251,6 +252,7 @@ export class Project extends Disposable {
}

history: History
historyMutex = new Mutex()

constructor() {
super()
Expand All @@ -267,10 +269,24 @@ export class Project extends Disposable {
return reactiveThis
}

private applyMetadata(metadata: Metadata) {
private loadMetadata(metadata: Metadata) {
assign<Project>(this, metadata)
}

private exportMetadata(): Metadata {
return {
id: this.id,
owner: this.owner,
name: this.name,
isPublic: this.isPublic,
version: this.version,
cTime: this.cTime,
uTime: this.uTime,
filesHash: this.filesHash,
lastSyncedFilesHash: this.lastSyncedFilesHash
}
}

async loadGameFiles(files: Files) {
const configFile = files[projectConfigFilePath]
const config: RawProjectConfig = {}
Expand Down Expand Up @@ -330,25 +346,13 @@ export class Project extends Disposable {

/** Load with metadata & game files */
async load(metadata: Metadata, files: Files) {
this.applyMetadata(metadata)
this.loadMetadata(metadata)
await this.loadGameFiles(files)
}

/** Export metadata & game files */
export(): [Metadata, Files] {
const metadata: Metadata = {
id: this.id,
owner: this.owner,
name: this.name,
isPublic: this.isPublic,
version: this.version,
cTime: this.cTime,
uTime: this.uTime,
filesHash: this.filesHash,
lastSyncedFilesHash: this.lastSyncedFilesHash
}
const files = this.exportGameFiles()
return [metadata, files]
async export(): Promise<[Metadata, Files]> {
return this.historyMutex.runExclusive(() => [this.exportMetadata(), this.exportGameFiles()])
}

async loadGbpFile(file: globalThis.File) {
Expand All @@ -363,7 +367,7 @@ export class Project extends Disposable {
}

async exportGbpFile() {
const [metadata, files] = this.export()
const [metadata, files] = await this.export()
return await gbpHelper.save(metadata, files)
}

Expand All @@ -380,9 +384,10 @@ export class Project extends Disposable {

/** Save to cloud */
async saveToCloud() {
const [metadata, files] = this.export()
const [metadata, files] = await this.export()
if (this.isDisposed) throw new Error('disposed')
const saved = await cloudHelper.save(metadata, files)
this.applyMetadata(saved.metadata)
this.loadMetadata(saved.metadata)
this.lastSyncedFilesHash = await hashFiles(files)
}

Expand All @@ -396,7 +401,8 @@ export class Project extends Disposable {

/** Save to local cache */
private async saveToLocalCache(key: string) {
const [metadata, files] = this.export()
const [metadata, files] = await this.export()
if (this.isDisposed) throw new Error('disposed')
await localHelper.save(key, metadata, files)
}

Expand Down Expand Up @@ -486,7 +492,11 @@ export class Project extends Disposable {
else delazyLoadGameFiles()
}
})()
this.addDisposer(watch(() => this.export(), autoSaveToLocalCache, { immediate: true }))
this.addDisposer(
watch(() => [this.exportMetadata(), this.exportGameFiles()], autoSaveToLocalCache, {
immediate: true
})
)

// watch for autoSaveMode switch, and trigger auto save accordingly
this.addDisposer(
Expand Down
16 changes: 12 additions & 4 deletions spx-gui/src/utils/disposable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,23 @@
export type Disposer = () => void

export class Disposable {
_disposers: Disposer[] = []
private disposers: Disposer[] = []

private _isDisposed = false
get isDisposed() {
return this._isDisposed
}

addDisposer = (disposer: Disposer) => {
this._disposers.push(disposer)
if (this._isDisposed) throw new Error('disposed')
this.disposers.push(disposer)
}

dispose = () => {
while (this._disposers.length > 0) {
this._disposers.pop()?.()
if (this._isDisposed) return
this._isDisposed = true
while (this.disposers.length > 0) {
this.disposers.pop()?.()
}
}
}
2 changes: 1 addition & 1 deletion spx-gui/src/utils/exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class DefaultException extends Exception {
}

/**
* Cancelled is a special exception, it stands for a "cancel operation" because of user ineraction.
* Cancelled is a special exception, it stands for a "cancel operation" because of user interaction.
* Like other exceptions, it breaks normal flows, while it is supposed to be ignored by all user-feedback components,
* so the user will not be notified of cancelled exceptions.
*/
Expand Down
Loading