From 9264f340cbf17eff18e74d599a47d6efc3ee4866 Mon Sep 17 00:00:00 2001 From: undefined Date: Wed, 19 Feb 2025 05:28:12 +0800 Subject: [PATCH] use cordis@4 --- framework/eslint-config/package.json | 4 +- framework/register/package.json | 2 +- package.json | 11 +- packages/hydrooj/package.json | 12 +- packages/hydrooj/src/context.ts | 110 ++++++++++++++-- packages/hydrooj/src/entry/cli.ts | 3 - packages/hydrooj/src/entry/worker.ts | 8 -- packages/hydrooj/src/handler/contest.ts | 2 +- packages/hydrooj/src/handler/problem.ts | 10 +- packages/hydrooj/src/init.ts | 2 +- packages/hydrooj/src/loader.ts | 95 +++++++------- packages/hydrooj/src/model/setting.ts | 2 +- packages/hydrooj/src/service/watcher.ts | 124 +++++++++++------- packages/hydrooj/src/service/worker.ts | 2 +- .../components/preview/preview.page.ts | 4 +- packages/ui-default/package.json | 22 ++-- packages/ui-default/polyfill.ts | 2 +- packages/vjudge/src/index.ts | 37 ++++-- 18 files changed, 284 insertions(+), 168 deletions(-) diff --git a/framework/eslint-config/package.json b/framework/eslint-config/package.json index d9ba7891e..3b8cc328c 100644 --- a/framework/eslint-config/package.json +++ b/framework/eslint-config/package.json @@ -5,8 +5,8 @@ "main": "react.yaml", "dependencies": { "@stylistic/eslint-plugin": "^3.1.0", - "@typescript-eslint/eslint-plugin": "^8.24.0", - "@typescript-eslint/parser": "^8.24.0", + "@typescript-eslint/eslint-plugin": "^8.24.1", + "@typescript-eslint/parser": "^8.24.1", "eslint-config-airbnb": "^19.0.4", "eslint-plugin-de-morgan": "^1.0.1", "eslint-plugin-github": "^5.1.8", diff --git a/framework/register/package.json b/framework/register/package.json index 712949235..a519cd9ea 100644 --- a/framework/register/package.json +++ b/framework/register/package.json @@ -7,7 +7,7 @@ "node": ">=16" }, "dependencies": { - "esbuild": "0.24.0", + "esbuild": "0.25.0", "source-map-support": "^0.5.21" }, "devDependencies": { diff --git a/package.json b/package.json index f96e996b2..1e565516b 100644 --- a/package.json +++ b/package.json @@ -36,16 +36,16 @@ "@types/autocannon": "^7.12.6", "@types/chai": "^5.0.1", "@types/cross-spawn": "^6.0.6", - "@types/node": "22.7.4", + "@types/node": "22.13.4", "@types/semver": "^7.5.8", "autocannon": "^8.0.0", "cac": "^6.7.14", - "chai": "^5.1.2", + "chai": "^5.2.0", "cross-env": "^7.0.3", "cross-spawn": "^7.0.6", - "esbuild": "0.24.0", + "esbuild": "0.25.0", "eslint": "^8.57.1", - "eslint-import-resolver-typescript": "^3.7.0", + "eslint-import-resolver-typescript": "^3.8.1", "eslint-import-resolver-webpack": "^0.13.10", "fs-extra": "^11.3.0", "globby": "~14.0.2", @@ -57,7 +57,8 @@ "semver": "^7.7.1", "simple-git": "^3.27.0", "supertest": "^7.0.0", - "typescript": "5.7.2", + "supports-hyperlinks": "^3.2.0", + "typescript": "5.7.3", "wtfnode": "^0.10.0" } } diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 674f43e5e..13fd518e1 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -16,7 +16,7 @@ "@aws-sdk/lib-storage": "3.726.1", "@aws-sdk/s3-presigned-post": "3.726.1", "@aws-sdk/s3-request-presigner": "3.726.1", - "@graphql-tools/schema": "^10.0.16", + "@graphql-tools/schema": "^10.0.18", "@hydrooj/common": "workspace:^", "@hydrooj/framework": "workspace:^", "@hydrooj/utils": "workspace:^", @@ -25,7 +25,7 @@ "@yarnpkg/libzip": "^3.1.0", "adm-zip": "0.5.14", "cac": "^6.7.14", - "cordis": "3.18.1", + "cordis": "4.0.0-alpha.0", "csv-stringify": "^6.5.2", "emoji-regex": "^10.4.0", "emojis-list": "2.1.0", @@ -41,22 +41,24 @@ "moment-timezone": "^0.5.47", "mongodb": "^5.9.2", "mongodb-uri": "^0.9.7", - "nanoid": "^5.0.9", + "nanoid": "^5.1.0", "nodemailer": "^6.10.0", "notp": "^2.0.3", "p-queue": "^8.1.0", "sanitize-filename": "^1.6.3", "saslprep": "^1.0.3", - "schemastery": "^3.15.0", + "schemastery": "^3.16.0", "semver": "^7.7.1", "shorty.js": "^1.0.1", "superagent": "^10.1.1", "tar": "6.2.1", "thirty-two": "^1.0.2", - "ua-parser-js": "^2.0.1" + "ua-parser-js": "^2.0.2" }, "devDependencies": { + "@babel/code-frame": "^7.26.2", "@types/adm-zip": "^0.4.34", + "@types/babel__code-frame": "^7.0.6", "@types/fs-extra": "^11.0.4", "@types/js-yaml": "^4.0.9", "@types/koa-static-cache": "^5.1.4", diff --git a/packages/hydrooj/src/context.ts b/packages/hydrooj/src/context.ts index 4c7346456..90b938438 100644 --- a/packages/hydrooj/src/context.ts +++ b/packages/hydrooj/src/context.ts @@ -24,10 +24,8 @@ function provideModule(type: T, id: string, mo } export type EffectScope = cordis.EffectScope; -export type ForkScope = cordis.ForkScope; -export type MainScope = cordis.MainScope; -export type { Disposable, ScopeStatus, Plugin } from 'cordis'; +export { Disposable, Plugin, ScopeStatus } from 'cordis'; export interface Context extends cordis.Context, Pick { // @ts-ignore @@ -40,12 +38,15 @@ export interface Context extends cordis.Context, Pick void, delay: number): () => void + setInterval(callback: () => void, delay: number): () => void + sleep(delay: number): Promise + throttle void>(callback: F, delay: number, noTrailing?: boolean): WithDispose + debounce void>(callback: F, delay: number): WithDispose } -export abstract class Service extends cordis.Service { - [cordis.Service.setup]() { - this.ctx = new Context() as C; - } +export abstract class Service extends cordis.Service { } const T = any>(origFunc: F, disposeFunc?) => @@ -56,7 +57,7 @@ const T = any>(origFunc: F, disposeFunc?) => }); }; -export class ApiMixin extends Service { +export class ApiMixin extends cordis.Service { addScript = T(addScript); setImmediate = T(setImmediate, clearImmediate); provideModule = T(provideModule); @@ -65,17 +66,104 @@ export class ApiMixin extends Service { this.ctx.emit('bus/broadcast', event, payload, process.env.TRACE_BROADCAST ? new Error().stack : null); constructor(ctx) { - super(ctx, '$api', true); + super(ctx, '$api'); ctx.mixin('$api', ['addScript', 'setImmediate', 'provideModule', 'injectUI', 'broadcast']); } } +type WithDispose = T & { dispose: () => void }; + +class TimerService extends Service { + constructor(ctx) { + super(ctx, '$timer'); + ctx.mixin('$timer', ['setTimeout', 'setInterval', 'sleep', 'throttle', 'debounce']); + } + + setTimeout(callback: () => void, delay: number) { + const dispose = this.ctx.effect(() => { + const timer = setTimeout(() => { + dispose(); + callback(); + }, delay); + return () => clearTimeout(timer); + }); + return dispose; + } + + setInterval(callback: () => void, delay: number) { + return this.ctx.effect(() => { + const timer = setInterval(callback, delay); + return () => clearInterval(timer); + }); + } + + sleep(delay: number) { + const caller = this.ctx; + return new Promise((resolve, reject) => { + const dispose1 = this.setTimeout(() => { + dispose1(); + dispose2(); // eslint-disable-line @typescript-eslint/no-use-before-define + resolve(); + }, delay); + const dispose2 = caller.on('dispose', () => { + dispose1(); + dispose2(); + reject(new Error('Context has been disposed')); + }); + }); + } + + private createWrapper(callback: (args: any[], check: () => boolean) => any, isDisposed = false) { + this.ctx.scope.assertActive(); + + let timer: number | NodeJS.Timeout | undefined; + const dispose = () => { + isDisposed = true; + remove(); // eslint-disable-line @typescript-eslint/no-use-before-define + clearTimeout(timer); + }; + + const wrapper: any = (...args: any[]) => { + clearTimeout(timer); + timer = callback(args, () => !isDisposed && this.ctx.scope.active); + }; + wrapper.dispose = dispose; + const remove = this.ctx.scope.disposables.push(dispose); + return wrapper; + } + + throttle void>(callback: F, delay: number, noTrailing?: boolean): WithDispose { + let lastCall = -Infinity; + const execute = (...args: any[]) => { + lastCall = Date.now(); + callback(...args); + }; + return this.createWrapper((args, isActive) => { // eslint-disable-line consistent-return + const now = Date.now(); + const remaining = delay - (now - lastCall); + if (remaining <= 0) { + execute(...args); + } else if (isActive()) { + return setTimeout(execute, remaining, ...args); + } + }, noTrailing); + } + + debounce void>(callback: F, delay: number): WithDispose { + return this.createWrapper((args, isActive) => { + if (!isActive()) return; + return setTimeout(callback, delay, ...args); // eslint-disable-line consistent-return + }); + } +} + export class Context extends cordis.Context { domain?: DomainDoc; - constructor(config: {} = {}) { - super(config); + constructor() { + super(); this.plugin(ApiMixin); + this.plugin(TimerService); } } diff --git a/packages/hydrooj/src/entry/cli.ts b/packages/hydrooj/src/entry/cli.ts index c60488d6e..333deb0cb 100644 --- a/packages/hydrooj/src/entry/cli.ts +++ b/packages/hydrooj/src/entry/cli.ts @@ -114,8 +114,6 @@ export async function load(ctx: Context) { await require('../settings').loadConfig(); await require('../model/system').runConfig(); await require('../service/storage').loadStorageService(); - await ctx.root.start(); - await ctx.lifecycle.flush(); require('../lib/index'); await Promise.all([ lib(pending, fail, ctx), @@ -132,6 +130,5 @@ export async function load(ctx: Context) { ctx.loader.reloadPlugin(ctx, path.resolve(scriptDir, h), {}, `hydrooj/script/${h.split('.')[0]}`); } await script(pending, fail, ctx); - await ctx.lifecycle.flush(); await cli(); } diff --git a/packages/hydrooj/src/entry/worker.ts b/packages/hydrooj/src/entry/worker.ts index 51be0b8ab..c22da2520 100644 --- a/packages/hydrooj/src/entry/worker.ts +++ b/packages/hydrooj/src/entry/worker.ts @@ -42,15 +42,11 @@ export async function apply(ctx: Context) { await storage.loadStorageService(); // Make sure everything is ready and then start main entry if (argv.options.watch) ctx.plugin(require('../service/watcher').default, {}); - await ctx.root.start(); - await ctx.lifecycle.flush(); await require('../service/worker').apply(ctx); await require('../service/server').apply(ctx); await require('../service/api').apply(ctx); - await ctx.lifecycle.flush(); require('../lib/index'); await lib(pending, fail, ctx); - await ctx.lifecycle.flush(); await setting(pending, fail, require('../model/setting')); ctx.plugin(require('../service/monitor')); @@ -58,7 +54,6 @@ export async function apply(ctx: Context) { await service(pending, fail, ctx); await builtinModel(ctx); await model(pending, fail, ctx); - await ctx.lifecycle.flush(); const handlerDir = path.resolve(__dirname, '..', 'handler'); const handlers = await fs.readdir(handlerDir); @@ -68,14 +63,11 @@ export async function apply(ctx: Context) { ctx.plugin(require('../service/migration').default); await handler(pending, fail, ctx); await addon(pending, fail, ctx); - await ctx.lifecycle.flush(); const scriptDir = path.resolve(__dirname, '..', 'script'); for (const h of await fs.readdir(scriptDir)) { ctx.loader.reloadPlugin(ctx, path.resolve(scriptDir, h), {}, `hydrooj/script/${h.split('.')[0]}`); } - await ctx.lifecycle.flush(); await script(pending, fail, ctx); - await ctx.lifecycle.flush(); await ctx.parallel('app/started'); if (process.env.NODE_APP_INSTANCE === '0') { await new Promise((resolve, reject) => { diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 0f1ae929d..0ab444a34 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -675,7 +675,7 @@ export class ContestScoreboardHandler extends ContestDetailBaseHandler { class ScoreboardService extends Service { views: Record> = {}; constructor(ctx: Context) { - super(ctx, 'scoreboard', true); + super(ctx, 'scoreboard'); ctx.set('scoreboard', this); } diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index fb414ad59..1c3473408 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -898,9 +898,8 @@ export class ProblemSolutionHandler extends ProblemDetailHandler { @param('content', Types.Content) async postEditReply(domainId: string, psid: ObjectId, psrid: ObjectId, content: string) { const [psdoc, psrdoc] = await solution.getReply(domainId, psid, psrid); - if ((!psdoc) || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(domainId, psid); - if (!(this.user.own(psrdoc) - && this.user.hasPerm(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF))) { + if (!psdoc || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(domainId, psid); + if (!this.user.own(psrdoc) || !this.user.hasPerm(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF)) { throw new PermissionError(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF); } await solution.editReply(domainId, psid, psrid, content); @@ -911,9 +910,8 @@ export class ProblemSolutionHandler extends ProblemDetailHandler { @param('psrid', Types.ObjectId) async postDeleteReply(domainId: string, psid: ObjectId, psrid: ObjectId) { const [psdoc, psrdoc] = await solution.getReply(domainId, psid, psrid); - if ((!psdoc) || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(psid); - if (!(this.user.own(psrdoc) - && this.user.hasPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY_SELF))) { + if (!psdoc || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(psid); + if (!this.user.own(psrdoc) || !this.user.hasPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY_SELF)) { this.checkPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY); } await solution.delReply(domainId, psid, psrid); diff --git a/packages/hydrooj/src/init.ts b/packages/hydrooj/src/init.ts index 0f0e24701..a31c020d1 100644 --- a/packages/hydrooj/src/init.ts +++ b/packages/hydrooj/src/init.ts @@ -33,5 +33,5 @@ if (!global.Hydro) { } global.app = new (require('./context').Context)(); process.on('exit', () => { - app.stop(); + }); diff --git a/packages/hydrooj/src/loader.ts b/packages/hydrooj/src/loader.ts index 34b5ed16e..fea27a0fe 100644 --- a/packages/hydrooj/src/loader.ts +++ b/packages/hydrooj/src/loader.ts @@ -14,7 +14,7 @@ import './ui'; import './lib/i18n'; import { Logger } from './logger'; -import { Context } from './context'; +import { Context, Service, ScopeStatus } from './context'; // eslint-disable-next-line import/no-duplicates import { sleep, unwrapExports } from './utils'; import { PRIV } from './model/builtin'; @@ -51,71 +51,72 @@ export function resolveConfig(plugin: any, config: any) { return config; } -const timeout = Symbol.for('loader.timeout'); -const showLoadTime = argv.options.showLoadTime; - -export class Loader { - static readonly Record = Symbol.for('loader.record'); - - public app: Context; +export class Loader extends Service { + public state: Record = Object.create(null); public config: {}; public suspend = false; public cache: Record = Object.create(null); + // public warnings: Record = Object.create(null); - unloadPlugin(ctx: Context, key: string) { - const fork = ctx.state[Loader.Record][key]; + constructor(private app: Context) { + super(app, 'loader'); + } + + [Service.setup]() { + this.app.on('app/started', () => { + this.ctx.setInterval(async () => { + const pending = Object.entries(this.state).filter((v) => v[1].status === ScopeStatus.PENDING); + if (pending.length) { + logger.warn('Plugins are still pending: %s', pending.map((v) => v[0]).join(', ')); + for (const [key, value] of pending) { + logger.warn('Plugin %s is still pending', key); + console.log(value); + } + } + const loading = Object.entries(this.state).filter((v) => v[1].status === ScopeStatus.LOADING); + if (loading.length) { + logger.warn('Plugins are still loading: %s', loading.map((v) => v[0]).join(', ')); + for (const [key, value] of loading) { + logger.warn('Plugin %s is still loading', key); + console.log(value); + } + } + const failed = Object.entries(this.state).filter((v) => v[1].status === ScopeStatus.FAILED); + if (failed.length) { + logger.warn('Plugins failed to load: %s', failed.map((v) => v[0]).join(', ')); + for (const [key, value] of failed) { + logger.warn('Plugin %s failed to load', key); + console.log(value); + } + } + }, 10000); + }); + } + + unloadPlugin(key: string) { + const fork = this.state[key]; if (fork) { fork.dispose(); - delete ctx.state[Loader.Record][key]; + delete this.state[key]; logger.info('unload plugin %c', key); } } async reloadPlugin(parent: Context, key: string, config: any, asName = '') { - let fork = parent.state[Loader.Record]?.[key]; + let fork = this.state[key]; if (fork) { logger.info('reload plugin %c', key.split('node_modules').pop()); fork.update(config); } else { logger.info('apply plugin %c', key.split('node_modules').pop()); - let plugin = await this.resolvePlugin(key); + const plugin = await this.resolvePlugin(key); if (!plugin) return; resolveConfig(plugin, config); if (asName) plugin.name = asName; // fork = parent.plugin(plugin, this.interpolate(config)); - if (plugin.apply) { - const original = plugin.apply; - const apply = (...args) => { - const start = Date.now(); - const result = Promise.resolve() - .then(() => original(...args)) - .catch((e) => logger.error(e)); - Promise.race([ - result, - new Promise((resolve) => { - setTimeout(() => resolve(timeout), 10000); - }), - ]).then((t) => { - if (t === timeout) { - logger.warn('Plugin %s took too long to load', key); - } - }); - if (showLoadTime && (typeof showLoadTime !== 'number' || Date.now() - start > showLoadTime)) { - logger.info('Plugin %s loaded in %dms', key, Date.now() - start); - } - return result; - }; - plugin = Object.create(plugin); - Object.defineProperty(plugin, 'apply', { - writable: true, - value: apply, - enumerable: true, - }); - } fork = parent.plugin(plugin, config); if (!fork) return; - parent.state[Loader.Record] ||= Object.create(null); - parent.state[Loader.Record][key] = fork; + this.state[key] = fork; } return fork; } @@ -135,11 +136,7 @@ export class Loader { } } -const loader = new Loader(); -app.provide('loader'); -app.loader = loader; -loader.app = app; -app.state[Loader.Record] = Object.create(null); +app.plugin(Loader); function preload() { for (const a of [path.resolve(__dirname, '..'), ...getAddons()]) { diff --git a/packages/hydrooj/src/model/setting.ts b/packages/hydrooj/src/model/setting.ts index 171db78a8..8d9902a2d 100644 --- a/packages/hydrooj/src/model/setting.ts +++ b/packages/hydrooj/src/model/setting.ts @@ -315,7 +315,7 @@ export class SettingService extends Service { DomainUserSetting = T(DomainUserSetting); SystemSetting = T(SystemSetting); constructor(ctx: Context) { - super(ctx, 'setting', true); + super(ctx, 'setting'); } get(key: string) { diff --git a/packages/hydrooj/src/service/watcher.ts b/packages/hydrooj/src/service/watcher.ts index 6b8dd2e07..35427ded7 100644 --- a/packages/hydrooj/src/service/watcher.ts +++ b/packages/hydrooj/src/service/watcher.ts @@ -2,13 +2,52 @@ /* eslint-disable consistent-return */ /* eslint-disable import/no-dynamic-require */ /* eslint-disable @typescript-eslint/no-shadow */ +import { readFileSync } from 'fs'; import { relative, resolve, sep } from 'path'; +import { codeFrameColumns } from '@babel/code-frame'; import { FSWatcher, watch } from 'chokidar'; -import { debounce } from 'lodash'; -import { Context, MainScope, Service } from '../context'; +import { BuildFailure } from 'esbuild'; +import { Context, Plugin, Service } from '../context'; import { Logger } from '../logger'; import { unwrapExports } from '../utils'; +declare module '../context' { + interface Events { + 'hmr/reload': (reloads: Map }>) => void; + } +} + +function isBuildFailure(e: any): e is BuildFailure { + return Array.isArray(e?.errors) && e.errors.every((error: any) => error.text); +} + +export function handleError(ctx: Context, e: any) { + if (!isBuildFailure(e)) { + ctx.logger.warn(e); + return; + } + + for (const error of e.errors) { + if (!error.location) { + ctx.logger.warn(error.text); + continue; + } + try { + const { file, line, column } = error.location; + const source = readFileSync(file, 'utf8'); + const formatted = codeFrameColumns(source, { + start: { line, column }, + }, { + highlightCode: true, + message: error.text, + }); + ctx.logger.warn(`File: ${file}:${line}:${column}\n${formatted}`); + } catch (e) { + ctx.logger.warn(e); + } + } +} + function loadDependencies(filename: string, ignored: Set) { const dependencies = new Set(); function traverse({ filename, children }: NodeJS.Module) { @@ -22,14 +61,6 @@ function loadDependencies(filename: string, ignored: Set) { const logger = new Logger('watch'); -function coerce(val: any) { - // resolve error when stack is undefined, e.g. axios error with status code 401 - const { message, stack } = val instanceof Error && val.stack ? val : new Error(val as any); - const lines = stack.split('\n'); - const index = lines.findIndex((line) => line.endsWith(message)); - return lines.slice(index).join('\n'); -} - export default class Watcher extends Service { private root: string; private watcher: FSWatcher; @@ -39,7 +70,7 @@ export default class Watcher extends Service { private stashed = new Set(); constructor(public ctx: Context) { - super(ctx, 'watcher', true); + super(ctx, 'watcher'); this.externals = new Set(Object.keys(require.cache)); } @@ -48,7 +79,6 @@ export default class Watcher extends Service { const roots = [this.root]; if (process.env.WATCH_ROOT) roots.push(process.env.WATCH_ROOT); this.watcher = watch(roots, { - ...this.config, ignored: (file) => [ 'node_modules', '.git', 'logs', '.cache', '.yarn', 'tsconfig.tsbuildinfo', ].some((rule) => file.startsWith(`${rule}/`) || file.endsWith(`/${rule}`) || file.includes(`/${rule}/`)), @@ -56,7 +86,7 @@ export default class Watcher extends Service { logger.info(`Start watching changes in ${this.root}`); // files independent from any plugins will trigger a full reload - const triggerLocalReload = debounce(() => this.triggerLocalReload(), 1000); + const triggerLocalReload = this.ctx.debounce(() => this.triggerLocalReload(), 1000); this.watcher.on('change', (path) => { if (path.includes(`${sep}.`)) return; @@ -140,14 +170,13 @@ export default class Watcher extends Service { } private triggerLocalReload() { - const start = Date.now(); this.analyzeChanges(); /** plugins pending classification */ - const pending = new Map(); + const pending = new Map>(); /** plugins that should be reloaded */ - const reloads = new Map(); + const reloads = new Map }>(); // we assume that plugin entry files are "atomic" // that is, reloading them will not cause any other reloads @@ -173,25 +202,13 @@ export default class Watcher extends Service { for (const dep of dependencies) this.accepted.add(dep); // prepare for reload - let isMarked = false; - const visited = new Set(); - const queued = [runtime]; - while (queued.length) { - const runtime = queued.shift(); - if (visited.has(runtime)) continue; - visited.add(runtime); - if (reloads.has(runtime)) { - isMarked = true; - break; - } - for (const state of runtime.children) { - queued.push(state.runtime); - } - } - if (!isMarked) reloads.set(runtime, filename); + reloads.set(runtime.plugin, { + filename, + runtime, + }); } - const backup: Record = {}; + const backup: Record = Object.create(null); for (const filename of this.accepted) { backup[filename] = require.cache[filename]; delete require.cache[filename]; @@ -204,54 +221,61 @@ export default class Watcher extends Service { } logger.debug('Will reload the following file(s): %o', this.accepted.keys()); - const attempts = {}; + const attempts: Record = {}; try { - for (const [, filename] of reloads) { + for (const [, { filename }] of reloads) { attempts[filename] = unwrapExports(require(filename)); } - } catch (err) { - logger.warn(err); + } catch (e) { + handleError(this.ctx, e); return rollback(); } + const reload = (plugin: any, runtime?: Plugin.Runtime) => { + if (!runtime) return; + for (const oldFiber of runtime.scopes) { + oldFiber.parent.plugin(plugin, oldFiber.config); + } + }; + try { - for (const [runtime, filename] of reloads) { + for (const [plugin, { filename, runtime }] of reloads) { const path = relative(this.root, filename); - const states = runtime.children.slice(); try { - this.ctx.registry.delete(runtime.plugin); + this.ctx.registry.delete(plugin); } catch (err) { - logger.warn(`failed to dispose plugin at %c\n${coerce(err)}`, path); + logger.warn('failed to dispose plugin at %c', path); + logger.warn(err); } try { - const plugin = attempts[filename]; - for (const state of states) { - state.parent.plugin(plugin, state.config); - } + reload(attempts[filename], runtime); + logger.info('reload plugin at %c', path); } catch (err) { - logger.warn(`failed to reload plugin at %c\n${coerce(err)}`, path); + logger.warn('failed to reload plugin at %c', path); + logger.warn(err); throw err; } } } catch { // rollback require.cache and plugin states rollback(); - for (const [runtime, filename] of reloads) { + for (const [plugin, { filename, runtime }] of reloads) { try { this.ctx.registry.delete(attempts[filename]); - runtime.parent.plugin(runtime.plugin, runtime.config); + reload(plugin, runtime); } catch (err) { logger.warn(err); } } - logger.warn('Rolling back changes'); return; } + // emit reload event on success + this.ctx.emit('hmr/reload', reloads); + // reset stashed files this.stashed = new Set(); - logger.success('Reload done in %d ms', Date.now() - start); } } diff --git a/packages/hydrooj/src/service/worker.ts b/packages/hydrooj/src/service/worker.ts index 9c1a2b70f..cffad83d0 100644 --- a/packages/hydrooj/src/service/worker.ts +++ b/packages/hydrooj/src/service/worker.ts @@ -17,7 +17,7 @@ export class WorkerService extends Service { coll = db.collection('schedule'); constructor(ctx: Context) { - super(ctx, 'worker', true); + super(ctx, 'worker'); this.consume(); } diff --git a/packages/ui-default/components/preview/preview.page.ts b/packages/ui-default/components/preview/preview.page.ts index 10d8a2e5a..1246e9a61 100644 --- a/packages/ui-default/components/preview/preview.page.ts +++ b/packages/ui-default/components/preview/preview.page.ts @@ -46,8 +46,8 @@ const dialogAction = (id) => [ ]; function bindCopyLink(id, src: string) { - const url = !(window.location.href.endsWith('file') || window.location.href.endsWith('files')) - || window.location.href.match('homework/.*/file') || window.location.href.match('training/.*/file') + const url = !['file', 'files'].some((i) => window.location.href.endsWith(i)) + || ['homework', 'training'].some((i) => window.location.href.match(`${i}/.*/file`)) ? `file://${src.substring(src.lastIndexOf('/') + 1)}` : src; const clip = new Clipboard(`#copy-${id}`, { text: () => `${url}` }); clip.on('success', () => Notification.success(i18n(`${url.startsWith('file://') ? 'Reference' : 'Download'} link copied to clipboard!`))); diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index da7cd7ea0..85b4e0852 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -14,8 +14,8 @@ "cli": false }, "devDependencies": { - "@blueprintjs/core": "^5.16.4", - "@cordisjs/core": "3.18.1", + "@blueprintjs/core": "^5.16.6", + "@cordisjs/core": "4.0.0-alpha.0", "@fontsource/dm-mono": "^5.1.1", "@fontsource/fira-code": "^5.1.1", "@fontsource/inconsolata": "^5.1.1", @@ -25,7 +25,7 @@ "@fontsource/source-code-pro": "^5.1.1", "@fontsource/ubuntu-mono": "^5.1.1", "@hydrooj/utils": "workspace:^", - "@sentry/browser": "^8.54.0", + "@sentry/browser": "^9.1.0", "@sentry/webpack-plugin": "^3.1.2", "@simplewebauthn/browser": "13.1.0", "@svgr/webpack": "^8.1.0", @@ -45,7 +45,7 @@ "@types/webpack-env": "^1.18.8", "@vscode/codicons": "^0.0.36", "ajv": "^8.17.1", - "allotment": "^1.20.2", + "allotment": "^1.20.3", "autoprefixer": "^10.4.20", "browser-update": "^3.3.55", "chalk": "^5.4.1", @@ -61,7 +61,7 @@ "echarts": "^5.6.0", "emojis-keywords": "2.0.0", "emojis-list": "2.1.0", - "esbuild-loader": "^4.2.2", + "esbuild-loader": "^4.3.0", "flatpickr": "^4.6.13", "graphiql": "^3.8.3", "idb": "^8.0.2", @@ -70,7 +70,7 @@ "jquery.easing": "^1.4.1", "jquery.transit": "^0.9.12", "matchmedia-polyfill": "^0.3.2", - "md-editor-rt": "^5.2.1", + "md-editor-rt": "^5.2.3", "mini-css-extract-plugin": "^2.9.2", "moment": "^2.30.1", "monaco-editor": "0.52.2", @@ -78,7 +78,7 @@ "monaco-editor-webpack-plugin": "^7.1.0", "monaco-themes": "^0.4.4", "monaco-yaml": "5.1.1", - "nanoid": "^5.0.9", + "nanoid": "^5.1.0", "normalize.css": "^8.0.1", "nprogress": "^0.2.0", "p-queue": "^8.1.0", @@ -102,21 +102,21 @@ "redux-promise-middleware": "^6.2.0", "redux-thunk": "^3.1.0", "rupture": "^0.7.1", - "schemastery": "^3.15.0", + "schemastery": "^3.16.0", "shorty.js": "^1.0.1", "slideout": "^1.0.1", "sticky-kit": "^1.1.3", "style-loader": "^4.0.0", "stylus": "^0.64.0", "stylus-loader": "7.1.2", - "svgtofont": "^6.2.0", + "svgtofont": "^6.3.0", "tether": "1.4.7", "tether-drop": "^1.4.2", "timeago-react": "^3.0.6", "timeago.js": "^4.0.2", "ts-loader": "^9.5.2", "web-streams-polyfill": "^4.1.0", - "webpack": "^5.97.1", + "webpack": "^5.98.0", "webpack-bundle-analyzer": "^4.10.2", "webpack-dev-server": "^5.2.0", "webpack-manifest-plugin": "^5.0.0", @@ -126,7 +126,7 @@ "@hydrooj/common": "workspace:^", "ansi_up": "^6.0.2", "cssfilter": "^0.0.11", - "esbuild": "0.24.0", + "esbuild": "0.25.0", "fs-extra": "^11.3.0", "html-to-text": "^9.0.5", "js-yaml": "^4.1.0", diff --git a/packages/ui-default/polyfill.ts b/packages/ui-default/polyfill.ts index f0270dade..7b3def5e3 100644 --- a/packages/ui-default/polyfill.ts +++ b/packages/ui-default/polyfill.ts @@ -31,7 +31,7 @@ if (typeof window['WeakRef'] === 'undefined') { return WeakRef; }(new WeakMap())); } -if (!(window.matchMedia('all').addListener || window.matchMedia('all').addEventListener)) { +if (!window.matchMedia('all').addListener && !window.matchMedia('all').addEventListener) { const localMatchMedia = window.matchMedia; const hasMediaQueries = localMatchMedia('only all').matches; let isListening = false; diff --git a/packages/vjudge/src/index.ts b/packages/vjudge/src/index.ts index 6c02b22b3..a35035bef 100644 --- a/packages/vjudge/src/index.ts +++ b/packages/vjudge/src/index.ts @@ -19,6 +19,7 @@ class AccountService { problemLists: Set; syncing = false; listUpdated = false; + stopped = false; working = false; error = ''; @@ -114,6 +115,7 @@ class AccountService { await sleep(5000); } page++; + if (this.stopped) return; pids = await this.api.listProblem(page, resync, list); } } @@ -153,13 +155,20 @@ class AccountService { this.syncing = false; } + async stop() { } // eslint-disable-line @typescript-eslint/no-empty-function + async main() { const res = await this.login(); if (!res) return; - setInterval(() => this.login(), Time.hour); - TaskModel.consume({ type: 'remotejudge', subType: this.account.type.split('.')[0] }, this.judge.bind(this), false); + const interval = setInterval(() => this.login(), Time.hour); + const consumer = TaskModel.consume({ type: 'remotejudge', subType: this.account.type.split('.')[0] }, this.judge.bind(this), false); this.working = true; this.handleSync(); + this.stop = async () => { + clearInterval(interval); + consumer.destroy(); + this.stopped = true; + }; } } @@ -171,13 +180,13 @@ declare module 'hydrooj' { class VJudgeService extends Service { constructor(ctx: Context) { - super(ctx, 'vjudge', false); + super(ctx, 'vjudge'); } accounts: RemoteAccount[]; private providers: Record = {}; private pool: Record = {}; - async start() { + async [Service.setup]() { this.accounts = await coll.find().toArray(); this.ctx.setInterval(this.sync.bind(this), Time.week); } @@ -185,11 +194,20 @@ class VJudgeService extends Service { addProvider(type: string, provider: BasicProvider, override = false) { if (process.env.VJUDGE_DEBUG && !(`,${process.env.VJUDGE_DEBUG},`).includes(`,${type},`)) return; if (!override && this.providers[type]) throw new Error(`duplicate provider ${type}`); - this.providers[type] = provider; - for (const account of this.accounts.filter((a) => a.type === type)) { - if (account.enableOn && !account.enableOn.includes(os.hostname())) continue; - this.pool[`${account.type}/${account.handle}`] = new AccountService(provider, account); - } + this.ctx.effect(() => { + this.providers[type] = provider; + const services = []; + for (const account of this.accounts.filter((a) => a.type === type)) { + if (account.enableOn && !account.enableOn.includes(os.hostname())) continue; + const service = new AccountService(provider, account); + services.push(service); + this.pool[`${account.type}/${account.handle}`] = service; + } + return () => { + for (const service of services) service.stop(); + delete this.providers[type]; + }; + }); // FIXME: potential race condition if (provider.Langs) this.updateLangs(type, provider.Langs); // TODO dispose session @@ -267,7 +285,6 @@ export async function apply(ctx: Context) { ]); }); ctx.inject(['vjudge'], async (c) => { - await c.vjudge.start(); for (const [k, v] of Object.entries(providers)) { if (!SystemModel.get(`vjudge.builtin-${k}-disable`)) c.vjudge.addProvider(k, v); }