Skip to content

Commit 7ac5457

Browse files
committed
fix(loader): support for nested isolate realms
1 parent b5b92f4 commit 7ac5457

File tree

4 files changed

+262
-29
lines changed

4 files changed

+262
-29
lines changed

packages/loader/src/entry.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,12 @@ export class Entry {
8383
}
8484

8585
// part 2: generate service diff
86-
const delimiter = Symbol('delimiter')
87-
ctx[delimiter] = true
88-
const diff: [string, symbol, symbol, boolean][] = []
86+
const diff: [string, symbol, symbol, symbol, symbol][] = []
8987
const oldMap = ctx[Context.isolate]
9088
for (const key in { ...oldMap, ...newMap }) {
9189
if (newMap[key] === oldMap[key]) continue
90+
const delim = this.loader.delims[key] ??= Symbol(key)
91+
ctx[delim] = Symbol(`${key}#${this.options.id}`)
9292
for (const symbol of [oldMap[key], newMap[key]]) {
9393
const value = symbol && ctx[symbol]
9494
if (!(value instanceof Object)) continue
@@ -97,45 +97,48 @@ export class Entry {
9797
this.parent.emit('internal/warning', new Error(`expected service ${key} to be implemented`))
9898
continue
9999
}
100-
diff.push([key, oldMap[key], newMap[key], !!source[delimiter]])
101-
break
100+
diff.push([key, oldMap[key], newMap[key], ctx[delim], source[delim]])
101+
if (ctx[delim] !== source[delim]) break
102102
}
103103
}
104104

105105
// part 3: emit service events
106106
// part 3.1: internal/before-service
107-
for (const [key, symbol1, symbol2, flag] of diff) {
107+
for (const [key, symbol1, symbol2, flag1, flag2] of diff) {
108108
const self = Object.create(null)
109109
self[Context.filter] = (target: Context) => {
110-
if (!target[delimiter] !== flag) return false
111-
if (symbol1 === target[Context.isolate][key]) return true
112-
return flag && symbol2 === target[Context.isolate][key]
110+
if (![symbol1, symbol2].includes(target[Context.isolate][key])) return false
111+
return (flag1 === target[this.loader.delims[key]]) !== (flag1 === flag2)
113112
}
114113
ctx.emit(self, 'internal/before-service', key)
115114
}
116115

117116
// part 3.2: update service impl, prevent double update
118117
this.fork?.update(this.options.config)
119118
swap(ctx[Context.isolate], newMap)
120-
for (const [, symbol1, symbol2, flag] of diff) {
121-
if (flag && ctx[symbol1] && !ctx[symbol2]) {
119+
for (const [, symbol1, symbol2, flag1, flag2] of diff) {
120+
if (flag1 === flag2 && ctx[symbol1] && !ctx[symbol2]) {
122121
ctx.root[symbol2] = ctx.root[symbol1]
123122
delete ctx.root[symbol1]
124123
}
125124
}
126125

127126
// part 3.3: internal/service
128-
for (const [key, symbol1, symbol2, flag] of diff) {
127+
for (const [key, symbol1, symbol2, flag1, flag2] of diff) {
129128
const self = Object.create(null)
130129
self[Context.filter] = (target: Context) => {
131-
if (!target[delimiter] !== flag) return false
132-
if (symbol2 === target[Context.isolate][key]) return true
133-
return flag && symbol1 === target[Context.isolate][key]
130+
if (![symbol1, symbol2].includes(target[Context.isolate][key])) return false
131+
return (flag1 === target[this.loader.delims[key]]) !== (flag1 === flag2)
134132
}
135133
ctx.emit(self, 'internal/service', key)
136134
}
137135

138-
delete ctx[delimiter]
136+
// part 4: clean up delimiter
137+
for (const key in this.loader.delims) {
138+
if (!Reflect.ownKeys(newMap).includes(key)) {
139+
delete ctx[this.loader.delims[key]]
140+
}
141+
}
139142
return ctx
140143
}
141144

packages/loader/src/shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export abstract class Loader<T extends Loader.Options = Loader.Options> extends
7070
public filename!: string
7171
public entries: Dict<Entry> = Object.create(null)
7272
public realms: Dict<Dict<symbol>> = Object.create(null)
73+
public delims: Dict<symbol> = Object.create(null)
7374

7475
private tasks = new Set<Promise<any>>()
7576
private _writeTask?: Promise<void>

packages/loader/tests/isolate.spec.ts

Lines changed: 235 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,7 @@ describe('service isolation: realm', async () => {
189189
root.plugin(MockLoader)
190190
const loader = root.loader
191191

192-
const dispose = mock.fn(() => {
193-
console.log(new Error())
194-
})
192+
const dispose = mock.fn()
195193

196194
const foo = Object.assign(loader.mock('foo', (ctx: Context) => {
197195
ctx.on('dispose', dispose)
@@ -288,14 +286,239 @@ describe('service isolation: realm', async () => {
288286
await new Promise((resolve) => setTimeout(resolve, 0))
289287
expect(foo.mock.calls).to.have.length(2)
290288
expect(dispose.mock.calls).to.have.length(0)
291-
expect(loader.entries[nested1]!.fork).to.be.ok
292-
expect(loader.entries[nested1]!.fork!.ctx.get('bar')!.value).to.equal('alpha')
293-
expect(loader.entries[nested1]!.fork!.status).to.equal(ScopeStatus.ACTIVE)
294-
expect(loader.entries[nested2]!.fork).to.be.ok
295-
expect(loader.entries[nested2]!.fork!.ctx.get('bar')!.value).to.equal('beta')
296-
expect(loader.entries[nested2]!.fork!.status).to.equal(ScopeStatus.ACTIVE)
297-
expect(loader.entries[nested3]!.fork).to.be.ok
298-
expect(loader.entries[nested3]!.fork!.ctx.get('bar')).to.be.undefined
299-
expect(loader.entries[nested3]!.fork!.status).to.equal(ScopeStatus.PENDING)
289+
const fork1 = loader.expectFork(nested1)
290+
expect(fork1.ctx.get('bar')!.value).to.equal('alpha')
291+
expect(fork1.status).to.equal(ScopeStatus.ACTIVE)
292+
const fork2 = loader.expectFork(nested2)
293+
expect(fork2.ctx.get('bar')!.value).to.equal('beta')
294+
expect(fork2.status).to.equal(ScopeStatus.ACTIVE)
295+
const fork3 = loader.expectFork(nested3)
296+
expect(fork3.ctx.get('bar')).to.be.undefined
297+
expect(fork3.status).to.equal(ScopeStatus.PENDING)
298+
})
299+
300+
it('special case: nested realms', async () => {
301+
const root = new Context()
302+
root.plugin(MockLoader)
303+
const loader = root.loader
304+
305+
const dispose = mock.fn()
306+
307+
const foo = Object.assign(loader.mock('foo', (ctx: Context) => {
308+
ctx.on('dispose', dispose)
309+
}), {
310+
inject: ['bar'],
311+
reusable: true,
312+
})
313+
314+
Object.assign(loader.mock('bar', (ctx: Context, config: {}) => {
315+
ctx.set('bar', config)
316+
}), {
317+
reusable: true,
318+
})
319+
320+
await loader.start()
321+
322+
const outer = await loader.create({
323+
name: 'cordis/group',
324+
config: [],
325+
})
326+
327+
const inner = await loader.create({
328+
name: 'cordis/group',
329+
isolate: {
330+
bar: 'custom',
331+
},
332+
config: [],
333+
}, outer)
334+
335+
await loader.create({
336+
name: 'bar',
337+
config: { value: 'custom' },
338+
}, inner)
339+
340+
const alpha = await loader.create({
341+
name: 'foo',
342+
isolate: {
343+
bar: 'custom',
344+
},
345+
})
346+
347+
const beta = await loader.create({
348+
name: 'foo',
349+
}, inner)
350+
351+
await new Promise((resolve) => setTimeout(resolve, 0))
352+
const fork1 = loader.expectFork(alpha)
353+
const fork2 = loader.expectFork(beta)
354+
expect(fork1.ctx.get('bar')!.value).to.equal('custom')
355+
expect(fork2.ctx.get('bar')!.value).to.equal('custom')
356+
357+
foo.mock.resetCalls()
358+
dispose.mock.resetCalls()
359+
360+
await loader.update(outer, {
361+
isolate: {
362+
bar: 'custom',
363+
},
364+
})
365+
366+
await new Promise((resolve) => setTimeout(resolve, 0))
367+
expect(foo.mock.calls).to.have.length(0)
368+
expect(dispose.mock.calls).to.have.length(0)
369+
370+
foo.mock.resetCalls()
371+
dispose.mock.resetCalls()
372+
373+
await loader.update(outer, {
374+
isolate: {},
375+
})
376+
377+
await new Promise((resolve) => setTimeout(resolve, 0))
378+
expect(foo.mock.calls).to.have.length(0)
379+
expect(dispose.mock.calls).to.have.length(0)
380+
})
381+
382+
it('special case: change provider', async () => {
383+
const root = new Context()
384+
root.plugin(MockLoader)
385+
const loader = root.loader
386+
387+
const dispose = mock.fn()
388+
389+
const foo = Object.assign(loader.mock('foo', (ctx: Context) => {
390+
ctx.on('dispose', dispose)
391+
}), {
392+
inject: ['bar'],
393+
reusable: true,
394+
})
395+
396+
Object.assign(loader.mock('bar', (ctx: Context, config: {}) => {
397+
ctx.set('bar', config)
398+
}), {
399+
reusable: true,
400+
})
401+
402+
await loader.start()
403+
404+
await loader.create({
405+
name: 'bar',
406+
isolate: {
407+
bar: 'alpha',
408+
},
409+
config: { value: 'alpha' },
410+
})
411+
412+
await loader.create({
413+
name: 'bar',
414+
isolate: {
415+
bar: 'beta',
416+
},
417+
config: { value: 'beta' },
418+
})
419+
420+
const group = await loader.create({
421+
name: 'cordis/group',
422+
isolate: {
423+
bar: 'alpha',
424+
},
425+
config: [],
426+
})
427+
428+
const id = await loader.create({
429+
name: 'foo',
430+
}, group)
431+
432+
await new Promise((resolve) => setTimeout(resolve, 0))
433+
expect(foo.mock.calls).to.have.length(1)
434+
expect(dispose.mock.calls).to.have.length(0)
435+
const fork = loader.expectFork(id)
436+
expect(fork.ctx.get('bar')!.value).to.equal('alpha')
437+
438+
foo.mock.resetCalls()
439+
dispose.mock.resetCalls()
440+
441+
await loader.update(group, {
442+
isolate: {
443+
bar: 'beta',
444+
},
445+
})
446+
447+
await new Promise((resolve) => setTimeout(resolve, 0))
448+
expect(foo.mock.calls).to.have.length(1)
449+
expect(dispose.mock.calls).to.have.length(1)
450+
expect(fork.ctx.get('bar')!.value).to.equal('beta')
451+
})
452+
453+
it('special case: change injector', async () => {
454+
const root = new Context()
455+
root.plugin(MockLoader)
456+
const loader = root.loader
457+
458+
const dispose = mock.fn()
459+
460+
const foo = Object.assign(loader.mock('foo', (ctx: Context) => {
461+
ctx.on('dispose', dispose)
462+
}), {
463+
inject: ['bar'],
464+
reusable: true,
465+
})
466+
467+
Object.assign(loader.mock('bar', (ctx: Context, config: {}) => {
468+
ctx.set('bar', config)
469+
}), {
470+
reusable: true,
471+
})
472+
473+
await loader.start()
474+
475+
const alpha = await loader.create({
476+
name: 'foo',
477+
isolate: {
478+
bar: 'alpha',
479+
},
480+
})
481+
482+
const beta = await loader.create({
483+
name: 'foo',
484+
isolate: {
485+
bar: 'beta',
486+
},
487+
})
488+
489+
const group = await loader.create({
490+
name: 'cordis/group',
491+
isolate: {
492+
bar: 'alpha',
493+
},
494+
config: [],
495+
})
496+
497+
await loader.create({
498+
name: 'bar',
499+
}, group)
500+
501+
await new Promise((resolve) => setTimeout(resolve, 0))
502+
expect(foo.mock.calls).to.have.length(1)
503+
expect(dispose.mock.calls).to.have.length(0)
504+
const fork1 = loader.expectFork(alpha)
505+
expect(fork1.ctx.get('bar')).to.be.ok
506+
const fork2 = loader.expectFork(beta)
507+
expect(fork2.ctx.get('bar')).to.be.undefined
508+
509+
foo.mock.resetCalls()
510+
dispose.mock.resetCalls()
511+
512+
await loader.update(group, {
513+
isolate: {
514+
bar: 'beta',
515+
},
516+
})
517+
518+
await new Promise((resolve) => setTimeout(resolve, 0))
519+
expect(foo.mock.calls).to.have.length(1)
520+
expect(dispose.mock.calls).to.have.length(1)
521+
expect(fork1.ctx.get('bar')).to.be.undefined
522+
expect(fork2.ctx.get('bar')).to.be.ok
300523
})
301524
})

packages/loader/tests/utils.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Dict } from 'cosmokit'
2-
import { Context, Plugin } from '@cordisjs/core'
2+
import { Context, ForkScope, Plugin } from '@cordisjs/core'
33
import { Entry, group, Loader } from '../src'
44
import { Mock, mock } from 'node:test'
55
import { expect } from 'chai'
@@ -9,6 +9,7 @@ declare module '../src/shared' {
99
mock<F extends Function>(name: string, plugin: F): Mock<F>
1010
expectEnable(plugin: any, config?: any): void
1111
expectDisable(plugin: any): void
12+
expectFork(id: string): ForkScope
1213
}
1314
}
1415

@@ -44,4 +45,9 @@ export default class MockLoader extends Loader {
4445
const runtime = this.ctx.registry.get(plugin)
4546
expect(runtime).to.be.not.ok
4647
}
48+
49+
expectFork(id: string) {
50+
expect(this.entries[id]?.fork).to.be.ok
51+
return this.entries[id]!.fork!
52+
}
4753
}

0 commit comments

Comments
 (0)