@marktrue/dsh-mobile-remote 3.2.1-alpha.3-app → 3.2.1-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +810 -0
- package/CONTRIBUTING.md +59 -0
- package/FAQ.md +111 -0
- package/README.md +253 -6
- package/cordis.patch.yml +14 -0
- package/docs/00-/345/274/200/345/217/221/346/200/273/347/272/262.md +122 -0
- package/docs/01-PRD.md +148 -0
- package/docs/02-architecture.md +249 -0
- package/docs/03-api.md +816 -0
- package/docs/04-security.md +96 -0
- package/docs/05-test-cases.md +326 -0
- package/docs/06-install-run.md +308 -0
- package/docs/07-user-manual.md +65 -0
- package/docs/08-/346/211/251/345/261/225/345/274/200/345/217/221/346/214/207/345/215/227.md +86 -0
- package/docs/09-compatibility.md +195 -0
- package/docs/10-release.md +927 -0
- package/docs/screenshots/01-home.png +0 -0
- package/docs/screenshots/02-notifications.png +0 -0
- package/docs/screenshots/03-sessions.png +0 -0
- package/docs/screenshots/04-chat.png +0 -0
- package/docs/screenshots/05-settings.png +0 -0
- package/docs/screenshots/06-new-session.png +0 -0
- package/docs/screenshots/pgy-appstore.jpg +0 -0
- package/docs/screenshots/pgy-mobile-app.jpg +0 -0
- package/docs/screenshots/pgy-pc-client.jpg +0 -0
- package/lib/account-usage.js +145 -0
- package/lib/app-alignment.js +112 -0
- package/lib/app-carrier-store.js +1972 -0
- package/lib/app-carrier.js +602 -0
- package/lib/client.js +706 -0
- package/lib/desktop-form.js +738 -0
- package/lib/endpoint-registry.js +162 -0
- package/lib/git-read-bundled-provider.js +255 -0
- package/lib/git-read-service.js +322 -0
- package/lib/index.js +9292 -0
- package/lib/plugin-content-digest.js +80 -0
- package/lib/plugin-self-update.js +758 -0
- package/lib/plugin-update-controller.js +95 -0
- package/lib/plugin-update-transaction.js +322 -0
- package/lib/plugin-update.js +309 -0
- package/lib/runtime-dependency-snapshot.js +345 -0
- package/lib/session-title-cache.js +123 -0
- package/lib/session-title-refresh.js +261 -0
- package/lib/update-fetch.js +24 -0
- package/lib/update-lock-arbiter.js +158 -0
- package/lib/update-supervisor.js +312 -0
- package/node_modules/ansi-regex/index.d.ts +37 -0
- package/node_modules/ansi-regex/index.js +10 -0
- package/node_modules/ansi-regex/license +9 -0
- package/node_modules/ansi-regex/package.json +55 -0
- package/node_modules/ansi-regex/readme.md +78 -0
- package/node_modules/ansi-styles/index.d.ts +345 -0
- package/node_modules/ansi-styles/index.js +163 -0
- package/node_modules/ansi-styles/license +9 -0
- package/node_modules/ansi-styles/package.json +56 -0
- package/node_modules/ansi-styles/readme.md +152 -0
- package/node_modules/camelcase/index.d.ts +63 -0
- package/node_modules/camelcase/index.js +76 -0
- package/node_modules/camelcase/license +9 -0
- package/node_modules/camelcase/package.json +43 -0
- package/node_modules/camelcase/readme.md +99 -0
- package/node_modules/cliui/CHANGELOG.md +76 -0
- package/node_modules/cliui/LICENSE.txt +14 -0
- package/node_modules/cliui/README.md +115 -0
- package/node_modules/cliui/index.js +354 -0
- package/node_modules/cliui/package.json +65 -0
- package/node_modules/color-convert/CHANGELOG.md +54 -0
- package/node_modules/color-convert/LICENSE +21 -0
- package/node_modules/color-convert/README.md +68 -0
- package/node_modules/color-convert/conversions.js +839 -0
- package/node_modules/color-convert/index.js +81 -0
- package/node_modules/color-convert/package.json +48 -0
- package/node_modules/color-convert/route.js +97 -0
- package/node_modules/color-name/LICENSE +8 -0
- package/node_modules/color-name/README.md +11 -0
- package/node_modules/color-name/index.js +152 -0
- package/node_modules/color-name/package.json +28 -0
- package/node_modules/decamelize/index.js +13 -0
- package/node_modules/decamelize/license +21 -0
- package/node_modules/decamelize/package.json +38 -0
- package/node_modules/decamelize/readme.md +48 -0
- package/node_modules/dijkstrajs/.travis.yml +4 -0
- package/node_modules/dijkstrajs/CONTRIBUTING.md +8 -0
- package/node_modules/dijkstrajs/LICENSE.md +19 -0
- package/node_modules/dijkstrajs/README.md +18 -0
- package/node_modules/dijkstrajs/dijkstra.js +165 -0
- package/node_modules/dijkstrajs/package.json +60 -0
- package/node_modules/dijkstrajs/test/dijkstra.test.js +96 -0
- package/node_modules/emoji-regex/LICENSE-MIT.txt +20 -0
- package/node_modules/emoji-regex/README.md +73 -0
- package/node_modules/emoji-regex/es2015/index.js +6 -0
- package/node_modules/emoji-regex/es2015/text.js +6 -0
- package/node_modules/emoji-regex/index.d.ts +23 -0
- package/node_modules/emoji-regex/index.js +6 -0
- package/node_modules/emoji-regex/package.json +50 -0
- package/node_modules/emoji-regex/text.js +6 -0
- package/node_modules/find-up/index.d.ts +137 -0
- package/node_modules/find-up/index.js +89 -0
- package/node_modules/find-up/license +9 -0
- package/node_modules/find-up/package.json +53 -0
- package/node_modules/find-up/readme.md +156 -0
- package/node_modules/get-caller-file/LICENSE.md +6 -0
- package/node_modules/get-caller-file/README.md +41 -0
- package/node_modules/get-caller-file/index.d.ts +2 -0
- package/node_modules/get-caller-file/index.js +22 -0
- package/node_modules/get-caller-file/index.js.map +1 -0
- package/node_modules/get-caller-file/package.json +42 -0
- package/node_modules/is-fullwidth-code-point/index.d.ts +17 -0
- package/node_modules/is-fullwidth-code-point/index.js +50 -0
- package/node_modules/is-fullwidth-code-point/license +9 -0
- package/node_modules/is-fullwidth-code-point/package.json +42 -0
- package/node_modules/is-fullwidth-code-point/readme.md +39 -0
- package/node_modules/locate-path/index.d.ts +83 -0
- package/node_modules/locate-path/index.js +65 -0
- package/node_modules/locate-path/license +9 -0
- package/node_modules/locate-path/package.json +45 -0
- package/node_modules/locate-path/readme.md +122 -0
- package/node_modules/p-limit/index.d.ts +38 -0
- package/node_modules/p-limit/index.js +57 -0
- package/node_modules/p-limit/license +9 -0
- package/node_modules/p-limit/package.json +52 -0
- package/node_modules/p-limit/readme.md +101 -0
- package/node_modules/p-locate/index.d.ts +64 -0
- package/node_modules/p-locate/index.js +52 -0
- package/node_modules/p-locate/license +9 -0
- package/node_modules/p-locate/package.json +53 -0
- package/node_modules/p-locate/readme.md +90 -0
- package/node_modules/p-try/index.d.ts +39 -0
- package/node_modules/p-try/index.js +9 -0
- package/node_modules/p-try/license +9 -0
- package/node_modules/p-try/package.json +42 -0
- package/node_modules/p-try/readme.md +58 -0
- package/node_modules/path-exists/index.d.ts +28 -0
- package/node_modules/path-exists/index.js +23 -0
- package/node_modules/path-exists/license +9 -0
- package/node_modules/path-exists/package.json +39 -0
- package/node_modules/path-exists/readme.md +52 -0
- package/node_modules/pngjs/.eslintignore +2 -0
- package/node_modules/pngjs/.eslintrc.json +19 -0
- package/node_modules/pngjs/.prettierignore +3 -0
- package/node_modules/pngjs/LICENSE +20 -0
- package/node_modules/pngjs/README.md +429 -0
- package/node_modules/pngjs/browser.js +16096 -0
- package/node_modules/pngjs/coverage/lcov-report/base.css +354 -0
- package/node_modules/pngjs/coverage/lcov-report/bitmapper.js.html +881 -0
- package/node_modules/pngjs/coverage/lcov-report/bitpacker.js.html +554 -0
- package/node_modules/pngjs/coverage/lcov-report/block-navigation.js +77 -0
- package/node_modules/pngjs/coverage/lcov-report/chunkstream.js.html +647 -0
- package/node_modules/pngjs/coverage/lcov-report/constants.js.html +176 -0
- package/node_modules/pngjs/coverage/lcov-report/crc.js.html +200 -0
- package/node_modules/pngjs/coverage/lcov-report/favicon.png +0 -0
- package/node_modules/pngjs/coverage/lcov-report/filter-pack.js.html +593 -0
- package/node_modules/pngjs/coverage/lcov-report/filter-parse-async.js.html +152 -0
- package/node_modules/pngjs/coverage/lcov-report/filter-parse-sync.js.html +143 -0
- package/node_modules/pngjs/coverage/lcov-report/filter-parse.js.html +611 -0
- package/node_modules/pngjs/coverage/lcov-report/format-normaliser.js.html +359 -0
- package/node_modules/pngjs/coverage/lcov-report/index.html +426 -0
- package/node_modules/pngjs/coverage/lcov-report/interlace.js.html +365 -0
- package/node_modules/pngjs/coverage/lcov-report/packer-async.js.html +230 -0
- package/node_modules/pngjs/coverage/lcov-report/packer-sync.js.html +248 -0
- package/node_modules/pngjs/coverage/lcov-report/packer.js.html +467 -0
- package/node_modules/pngjs/coverage/lcov-report/paeth-predictor.js.html +128 -0
- package/node_modules/pngjs/coverage/lcov-report/parser-async.js.html +575 -0
- package/node_modules/pngjs/coverage/lcov-report/parser-sync.js.html +404 -0
- package/node_modules/pngjs/coverage/lcov-report/parser.js.html +950 -0
- package/node_modules/pngjs/coverage/lcov-report/png-sync.js.html +116 -0
- package/node_modules/pngjs/coverage/lcov-report/png.js.html +662 -0
- package/node_modules/pngjs/coverage/lcov-report/prettify.css +101 -0
- package/node_modules/pngjs/coverage/lcov-report/prettify.js +1007 -0
- package/node_modules/pngjs/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/node_modules/pngjs/coverage/lcov-report/sorter.js +169 -0
- package/node_modules/pngjs/coverage/lcov-report/sync-inflate.js.html +584 -0
- package/node_modules/pngjs/coverage/lcov-report/sync-reader.js.html +215 -0
- package/node_modules/pngjs/coverage/lcov.info +2177 -0
- package/node_modules/pngjs/lib/bitmapper.js +267 -0
- package/node_modules/pngjs/lib/bitpacker.js +158 -0
- package/node_modules/pngjs/lib/chunkstream.js +189 -0
- package/node_modules/pngjs/lib/constants.js +32 -0
- package/node_modules/pngjs/lib/crc.js +40 -0
- package/node_modules/pngjs/lib/filter-pack.js +171 -0
- package/node_modules/pngjs/lib/filter-parse-async.js +24 -0
- package/node_modules/pngjs/lib/filter-parse-sync.js +21 -0
- package/node_modules/pngjs/lib/filter-parse.js +177 -0
- package/node_modules/pngjs/lib/format-normaliser.js +93 -0
- package/node_modules/pngjs/lib/interlace.js +95 -0
- package/node_modules/pngjs/lib/packer-async.js +50 -0
- package/node_modules/pngjs/lib/packer-sync.js +56 -0
- package/node_modules/pngjs/lib/packer.js +129 -0
- package/node_modules/pngjs/lib/paeth-predictor.js +16 -0
- package/node_modules/pngjs/lib/parser-async.js +165 -0
- package/node_modules/pngjs/lib/parser-sync.js +108 -0
- package/node_modules/pngjs/lib/parser.js +290 -0
- package/node_modules/pngjs/lib/png-sync.js +12 -0
- package/node_modules/pngjs/lib/png.js +194 -0
- package/node_modules/pngjs/lib/sync-inflate.js +168 -0
- package/node_modules/pngjs/lib/sync-reader.js +45 -0
- package/node_modules/pngjs/package.json +73 -0
- package/node_modules/qrcode/README.md +770 -0
- package/node_modules/qrcode/bin/qrcode +159 -0
- package/node_modules/qrcode/helper/to-sjis-browser.js +2 -0
- package/node_modules/qrcode/helper/to-sjis.js +105 -0
- package/node_modules/qrcode/lib/browser.js +76 -0
- package/node_modules/qrcode/lib/can-promise.js +7 -0
- package/node_modules/qrcode/lib/core/alignment-pattern.js +83 -0
- package/node_modules/qrcode/lib/core/alphanumeric-data.js +59 -0
- package/node_modules/qrcode/lib/core/bit-buffer.js +37 -0
- package/node_modules/qrcode/lib/core/bit-matrix.js +65 -0
- package/node_modules/qrcode/lib/core/byte-data.js +30 -0
- package/node_modules/qrcode/lib/core/error-correction-code.js +135 -0
- package/node_modules/qrcode/lib/core/error-correction-level.js +50 -0
- package/node_modules/qrcode/lib/core/finder-pattern.js +22 -0
- package/node_modules/qrcode/lib/core/format-info.js +29 -0
- package/node_modules/qrcode/lib/core/galois-field.js +69 -0
- package/node_modules/qrcode/lib/core/kanji-data.js +54 -0
- package/node_modules/qrcode/lib/core/mask-pattern.js +234 -0
- package/node_modules/qrcode/lib/core/mode.js +167 -0
- package/node_modules/qrcode/lib/core/numeric-data.js +43 -0
- package/node_modules/qrcode/lib/core/polynomial.js +62 -0
- package/node_modules/qrcode/lib/core/qrcode.js +495 -0
- package/node_modules/qrcode/lib/core/reed-solomon-encoder.js +56 -0
- package/node_modules/qrcode/lib/core/regex.js +31 -0
- package/node_modules/qrcode/lib/core/segments.js +330 -0
- package/node_modules/qrcode/lib/core/utils.js +63 -0
- package/node_modules/qrcode/lib/core/version-check.js +9 -0
- package/node_modules/qrcode/lib/core/version.js +163 -0
- package/node_modules/qrcode/lib/index.js +12 -0
- package/node_modules/qrcode/lib/renderer/canvas.js +63 -0
- package/node_modules/qrcode/lib/renderer/png.js +78 -0
- package/node_modules/qrcode/lib/renderer/svg-tag.js +81 -0
- package/node_modules/qrcode/lib/renderer/svg.js +19 -0
- package/node_modules/qrcode/lib/renderer/terminal/terminal-small.js +85 -0
- package/node_modules/qrcode/lib/renderer/terminal/terminal.js +49 -0
- package/node_modules/qrcode/lib/renderer/terminal.js +9 -0
- package/node_modules/qrcode/lib/renderer/utf8.js +71 -0
- package/node_modules/qrcode/lib/renderer/utils.js +99 -0
- package/node_modules/qrcode/lib/server.js +138 -0
- package/node_modules/qrcode/license +10 -0
- package/node_modules/qrcode/package.json +77 -0
- package/node_modules/require-directory/.jshintrc +67 -0
- package/node_modules/require-directory/.npmignore +1 -0
- package/node_modules/require-directory/.travis.yml +3 -0
- package/node_modules/require-directory/LICENSE +22 -0
- package/node_modules/require-directory/README.markdown +184 -0
- package/node_modules/require-directory/index.js +86 -0
- package/node_modules/require-directory/package.json +40 -0
- package/node_modules/require-main-filename/CHANGELOG.md +26 -0
- package/node_modules/require-main-filename/LICENSE.txt +14 -0
- package/node_modules/require-main-filename/README.md +26 -0
- package/node_modules/require-main-filename/index.js +18 -0
- package/node_modules/require-main-filename/package.json +35 -0
- package/node_modules/set-blocking/CHANGELOG.md +26 -0
- package/node_modules/set-blocking/LICENSE.txt +14 -0
- package/node_modules/set-blocking/README.md +31 -0
- package/node_modules/set-blocking/index.js +7 -0
- package/node_modules/set-blocking/package.json +42 -0
- package/node_modules/string-width/index.d.ts +29 -0
- package/node_modules/string-width/index.js +47 -0
- package/node_modules/string-width/license +9 -0
- package/node_modules/string-width/package.json +56 -0
- package/node_modules/string-width/readme.md +50 -0
- package/node_modules/strip-ansi/index.d.ts +17 -0
- package/node_modules/strip-ansi/index.js +4 -0
- package/node_modules/strip-ansi/license +9 -0
- package/node_modules/strip-ansi/package.json +54 -0
- package/node_modules/strip-ansi/readme.md +46 -0
- package/node_modules/which-module/LICENSE +13 -0
- package/node_modules/which-module/README.md +58 -0
- package/node_modules/which-module/index.js +9 -0
- package/node_modules/which-module/package.json +41 -0
- package/node_modules/wrap-ansi/index.js +186 -0
- package/node_modules/wrap-ansi/license +9 -0
- package/node_modules/wrap-ansi/package.json +61 -0
- package/node_modules/wrap-ansi/readme.md +97 -0
- package/node_modules/y18n/CHANGELOG.md +35 -0
- package/node_modules/y18n/LICENSE +13 -0
- package/node_modules/y18n/README.md +109 -0
- package/node_modules/y18n/index.js +188 -0
- package/node_modules/y18n/package.json +39 -0
- package/node_modules/yargs/CHANGELOG.md +420 -0
- package/node_modules/yargs/LICENSE +21 -0
- package/node_modules/yargs/README.md +140 -0
- package/node_modules/yargs/build/lib/apply-extends.d.ts +2 -0
- package/node_modules/yargs/build/lib/apply-extends.js +65 -0
- package/node_modules/yargs/build/lib/argsert.d.ts +2 -0
- package/node_modules/yargs/build/lib/argsert.js +65 -0
- package/node_modules/yargs/build/lib/command.d.ts +64 -0
- package/node_modules/yargs/build/lib/command.js +416 -0
- package/node_modules/yargs/build/lib/common-types.d.ts +36 -0
- package/node_modules/yargs/build/lib/common-types.js +25 -0
- package/node_modules/yargs/build/lib/completion-templates.d.ts +2 -0
- package/node_modules/yargs/build/lib/completion-templates.js +50 -0
- package/node_modules/yargs/build/lib/completion.d.ts +21 -0
- package/node_modules/yargs/build/lib/completion.js +135 -0
- package/node_modules/yargs/build/lib/is-promise.d.ts +1 -0
- package/node_modules/yargs/build/lib/is-promise.js +9 -0
- package/node_modules/yargs/build/lib/levenshtein.d.ts +1 -0
- package/node_modules/yargs/build/lib/levenshtein.js +58 -0
- package/node_modules/yargs/build/lib/middleware.d.ts +10 -0
- package/node_modules/yargs/build/lib/middleware.js +57 -0
- package/node_modules/yargs/build/lib/obj-filter.d.ts +1 -0
- package/node_modules/yargs/build/lib/obj-filter.js +14 -0
- package/node_modules/yargs/build/lib/parse-command.d.ts +11 -0
- package/node_modules/yargs/build/lib/parse-command.js +36 -0
- package/node_modules/yargs/build/lib/process-argv.d.ts +2 -0
- package/node_modules/yargs/build/lib/process-argv.js +31 -0
- package/node_modules/yargs/build/lib/usage.d.ts +49 -0
- package/node_modules/yargs/build/lib/usage.js +540 -0
- package/node_modules/yargs/build/lib/validation.d.ts +34 -0
- package/node_modules/yargs/build/lib/validation.js +330 -0
- package/node_modules/yargs/build/lib/yargs.d.ts +274 -0
- package/node_modules/yargs/build/lib/yargs.js +1190 -0
- package/node_modules/yargs/build/lib/yerror.d.ts +4 -0
- package/node_modules/yargs/build/lib/yerror.js +11 -0
- package/node_modules/yargs/index.js +40 -0
- package/node_modules/yargs/locales/be.json +46 -0
- package/node_modules/yargs/locales/de.json +46 -0
- package/node_modules/yargs/locales/en.json +51 -0
- package/node_modules/yargs/locales/es.json +46 -0
- package/node_modules/yargs/locales/fi.json +49 -0
- package/node_modules/yargs/locales/fr.json +53 -0
- package/node_modules/yargs/locales/hi.json +49 -0
- package/node_modules/yargs/locales/hu.json +46 -0
- package/node_modules/yargs/locales/id.json +50 -0
- package/node_modules/yargs/locales/it.json +46 -0
- package/node_modules/yargs/locales/ja.json +51 -0
- package/node_modules/yargs/locales/ko.json +49 -0
- package/node_modules/yargs/locales/nb.json +44 -0
- package/node_modules/yargs/locales/nl.json +49 -0
- package/node_modules/yargs/locales/nn.json +44 -0
- package/node_modules/yargs/locales/pirate.json +13 -0
- package/node_modules/yargs/locales/pl.json +49 -0
- package/node_modules/yargs/locales/pt.json +45 -0
- package/node_modules/yargs/locales/pt_BR.json +48 -0
- package/node_modules/yargs/locales/ru.json +46 -0
- package/node_modules/yargs/locales/th.json +46 -0
- package/node_modules/yargs/locales/tr.json +48 -0
- package/node_modules/yargs/locales/zh_CN.json +48 -0
- package/node_modules/yargs/locales/zh_TW.json +47 -0
- package/node_modules/yargs/package.json +92 -0
- package/node_modules/yargs/yargs.js +14 -0
- package/node_modules/yargs-parser/CHANGELOG.md +601 -0
- package/node_modules/yargs-parser/LICENSE.txt +14 -0
- package/node_modules/yargs-parser/README.md +449 -0
- package/node_modules/yargs-parser/index.js +1032 -0
- package/node_modules/yargs-parser/lib/tokenize-arg-string.js +40 -0
- package/node_modules/yargs-parser/package.json +46 -0
- package/package.json +65 -10
- package/DSH-Remote-v3.2.1.apk +0 -4
- package/manifest.json +0 -21
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// issue #20 第 2 步(实现要点 1):**revision 失效的持久化标题缓存**。
|
|
2
|
+
//
|
|
3
|
+
// 背景(见 issue #20 的 note 702 / 793):
|
|
4
|
+
// - `/sessions` 在响应前同步折叠**全部休眠会话**的标题,而折叠要完整读取每个会话日志
|
|
5
|
+
// (追加式多帧 zstd)——隔离实测冷缓存 68.2 s、生产 600 s 未返回。
|
|
6
|
+
// - 内核 `sessionQuery.observeSession(id)` 返回的 lease 带 `revision`(来自**不读日志**的
|
|
7
|
+
// `persistence.stat()`)。revision 未变即意味着日志没变 → **可以零日志读取地复用标题**。
|
|
8
|
+
// - 但 `stat()` 本身也不便宜(实测 61.9 ms/会话 → 全量 354 个 21.9 s),所以本缓存
|
|
9
|
+
// **必须同时有界**:条目数有界、每次请求的发散量有界(由调用方的时间预算控制)。
|
|
10
|
+
//
|
|
11
|
+
// 本模块只负责"存与取",不做调度——预算与 single-flight 由调用方掌握。
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
|
|
16
|
+
/** 默认条目上限:约 1000 个会话足够覆盖常见语料,且文件体积可控(~100 KB 量级)。 */
|
|
17
|
+
export const DEFAULT_MAX_ENTRIES = 1000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {string} options.file 落盘路径(`~/.dsh/mobile-remote/session-titles.json`)
|
|
22
|
+
* @param {number} [options.maxEntries] 条目上限(超出按最久未更新淘汰)
|
|
23
|
+
* @param {() => number} [options.now] 时间源(测试可注入)
|
|
24
|
+
*/
|
|
25
|
+
export const createTitleCache = ({ file, maxEntries = DEFAULT_MAX_ENTRIES, now = Date.now }) => {
|
|
26
|
+
/** sessionId -> { title: string|null, revision: string, at: number } */
|
|
27
|
+
const entries = new Map();
|
|
28
|
+
let dirty = false;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 读盘并替换当前内存快照。只接受 version 1;缺失/损坏/未知格式一律降级为空缓存,
|
|
32
|
+
* 缓存坏了不该让列表挂掉,也不能把不可信字段当成已确认的标题事实。
|
|
33
|
+
*/
|
|
34
|
+
const load = () => {
|
|
35
|
+
entries.clear();
|
|
36
|
+
dirty = false;
|
|
37
|
+
try {
|
|
38
|
+
if (!existsSync(file)) return;
|
|
39
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
40
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.version !== 1) return;
|
|
41
|
+
const raw = parsed.entries;
|
|
42
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
43
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
44
|
+
if (id === "") continue;
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
46
|
+
if (typeof value.revision !== "string" || value.revision === "") continue;
|
|
47
|
+
// 只有显式 null 才代表“确实没有标题”;缺失或其它类型必须回源,不能伪造命中。
|
|
48
|
+
if (value.title !== null && typeof value.title !== "string") continue;
|
|
49
|
+
const title = value.title === "" ? null : value.title;
|
|
50
|
+
entries.set(id, { title, revision: value.revision, at: typeof value.at === "number" ? value.at : 0 });
|
|
51
|
+
}
|
|
52
|
+
prune();
|
|
53
|
+
} catch {
|
|
54
|
+
entries.clear();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 取缓存标题。
|
|
60
|
+
* @returns `{ hit: true, title }` 表示 **revision 相同、可零日志读取地复用**;
|
|
61
|
+
* `{ hit: false }` 表示需要回源折叠。
|
|
62
|
+
*/
|
|
63
|
+
const get = (id, revision) => {
|
|
64
|
+
if (typeof id !== "string" || id === "" || typeof revision !== "string" || revision === "") {
|
|
65
|
+
return { hit: false };
|
|
66
|
+
}
|
|
67
|
+
const entry = entries.get(id);
|
|
68
|
+
if (!entry || entry.revision !== revision) return { hit: false };
|
|
69
|
+
// 命中也要刷新时间戳:淘汰按"最久未更新",命中说明这个会话仍活跃。
|
|
70
|
+
entry.at = now();
|
|
71
|
+
dirty = true;
|
|
72
|
+
return { hit: true, title: entry.title };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** 写入(title 允许为 null——"确实没有标题"同样是可缓存的事实)。 */
|
|
76
|
+
const set = (id, revision, title) => {
|
|
77
|
+
if (typeof id !== "string" || id === "" || typeof revision !== "string" || revision === "") return;
|
|
78
|
+
entries.set(id, {
|
|
79
|
+
title: typeof title === "string" && title !== "" ? title : null,
|
|
80
|
+
revision,
|
|
81
|
+
at: now(),
|
|
82
|
+
});
|
|
83
|
+
dirty = true;
|
|
84
|
+
prune();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** 按 `at` 淘汰到上限以内。 */
|
|
88
|
+
const prune = () => {
|
|
89
|
+
if (entries.size <= maxEntries) return;
|
|
90
|
+
const sorted = [...entries.entries()].sort((a, b) => a[1].at - b[1].at);
|
|
91
|
+
const excess = entries.size - maxEntries;
|
|
92
|
+
for (let i = 0; i < excess; i += 1) {
|
|
93
|
+
entries.delete(sorted[i][0]);
|
|
94
|
+
dirty = true;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** 原子落盘(临时文件 + rename)。没有变更时是空操作。 */
|
|
99
|
+
const flush = () => {
|
|
100
|
+
if (!dirty) return false;
|
|
101
|
+
try {
|
|
102
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
103
|
+
const tmp = `${file}.tmp`;
|
|
104
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, entries: Object.fromEntries(entries) }), "utf8");
|
|
105
|
+
renameSync(tmp, file);
|
|
106
|
+
dirty = false;
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
// 落盘失败不该影响本次响应:内存缓存仍然有效。
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
load,
|
|
116
|
+
get,
|
|
117
|
+
set,
|
|
118
|
+
flush,
|
|
119
|
+
size: () => entries.size,
|
|
120
|
+
/** 仅测试用:直接观察某条目。 */
|
|
121
|
+
peek: (id) => entries.get(id),
|
|
122
|
+
};
|
|
123
|
+
};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
export const DEFAULT_TITLE_REFRESH_BUDGET_MS = 1500;
|
|
2
|
+
export const DEFAULT_TITLE_REFRESH_CONCURRENCY = 4;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Coalesce title refreshes for one corpus pass.
|
|
6
|
+
*
|
|
7
|
+
* Why a pass folds in **one** host call (issue #20 review, 2026-10-07):
|
|
8
|
+
* `sessionQuery.readTitleSnapshots(ids)` is not a point read. The installed
|
|
9
|
+
* `0.2.0-rc.2` chain is
|
|
10
|
+
*
|
|
11
|
+
* readTitleSnapshots → SessionCorpus.projectMany → listPersisted → persistence.list()
|
|
12
|
+
*
|
|
13
|
+
* and `persistence.list()` walks the whole corpus (per-file header + stat, plus
|
|
14
|
+
* a corpus-wide revision hash while a historical generation is present). Batch
|
|
15
|
+
* size therefore multiplies corpus enumerations: folding four ids at a time
|
|
16
|
+
* cost ceil(N/4) full enumerations on top of the pass. The pass now hands every
|
|
17
|
+
* pending id to one call, so the enumeration count stays constant as N grows;
|
|
18
|
+
* read width inside that call is the host's own `persistedReadConcurrency`
|
|
19
|
+
* (default 4, `SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY`, bounded by
|
|
20
|
+
* `projectMany`'s worker pool), and this module adds no per-id fan-out on top of
|
|
21
|
+
* it. Only the plugin's own revision scan is bounded here — it reads no logs.
|
|
22
|
+
*
|
|
23
|
+
* Waiters may leave independently; the shared pass is cancelled only after its
|
|
24
|
+
* last waiter leaves, unless a timed-out response promoted it to deliberate
|
|
25
|
+
* background warm-up. A cancelled pass is retired immediately so the next
|
|
26
|
+
* request starts a live successor instead of feeding the dead queue, while the
|
|
27
|
+
* fold gate keeps a retired pass and its successor from overlapping — an
|
|
28
|
+
* overlap would double the host's read width during cancellation cleanup.
|
|
29
|
+
*/
|
|
30
|
+
export const createSessionTitleRefresher = ({
|
|
31
|
+
readRevision,
|
|
32
|
+
readTitles,
|
|
33
|
+
getCachedTitle,
|
|
34
|
+
setCachedTitle,
|
|
35
|
+
onCacheChange = () => {},
|
|
36
|
+
concurrency = DEFAULT_TITLE_REFRESH_CONCURRENCY,
|
|
37
|
+
} = {}) => {
|
|
38
|
+
if (typeof readTitles !== "function") throw new TypeError("readTitles must be a function");
|
|
39
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1) throw new RangeError("concurrency must be a positive safe integer");
|
|
40
|
+
let currentRun = null;
|
|
41
|
+
let disposed = false;
|
|
42
|
+
// Serializes fold calls across runs: at most one host title call is in flight
|
|
43
|
+
// per refresher, so cancellation cleanup cannot overlap fresh read work.
|
|
44
|
+
let foldTail = Promise.resolve();
|
|
45
|
+
|
|
46
|
+
const foldAll = (ids, signal) => {
|
|
47
|
+
const previous = foldTail;
|
|
48
|
+
let release;
|
|
49
|
+
foldTail = new Promise((resolve) => { release = resolve; });
|
|
50
|
+
return (async () => {
|
|
51
|
+
try {
|
|
52
|
+
if (signal.aborted) throw signal.reason ?? new Error("session title refresh aborted");
|
|
53
|
+
await previous.catch(() => {});
|
|
54
|
+
if (signal.aborted) throw signal.reason ?? new Error("session title refresh aborted");
|
|
55
|
+
return await readTitles(ids, signal);
|
|
56
|
+
} finally {
|
|
57
|
+
release();
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const finishRun = (run) => {
|
|
63
|
+
run.done = true;
|
|
64
|
+
if (run.deadlineTimer) {
|
|
65
|
+
clearTimeout(run.deadlineTimer);
|
|
66
|
+
run.deadlineTimer = null;
|
|
67
|
+
}
|
|
68
|
+
if (currentRun === run) currentRun = null;
|
|
69
|
+
run.resolve();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// issue #28 复核 2:**run 级**有限寿命(源级政策)。
|
|
73
|
+
// 调用者级的 `backgroundOnBudget: false` 只约束「谁不提升」——挡不住后来加入的 HTTP 调用者
|
|
74
|
+
// 在自己的响应预算到点时把 run 提升为**永久后台**;坏源一旦永生,之后健康的请求会一直 join 它
|
|
75
|
+
// (只拿到短码),失败隔离就没有闭环。故把截止放在 run 上:到点即中止并退休,**不看 background**。
|
|
76
|
+
const armRunDeadline = (run) => {
|
|
77
|
+
if (!Number.isFinite(run.deadlineAt) || run.deadlineTimer) return;
|
|
78
|
+
const remaining = Math.max(0, run.deadlineAt - performance.now());
|
|
79
|
+
run.deadlineTimer = setTimeout(() => {
|
|
80
|
+
run.deadlineTimer = null;
|
|
81
|
+
if (run.done || run.controller.signal.aborted) return;
|
|
82
|
+
run.controller.abort(new Error("session-title refresh run exceeded its deadline"));
|
|
83
|
+
if (currentRun === run) currentRun = null;
|
|
84
|
+
}, remaining);
|
|
85
|
+
run.deadlineTimer.unref?.();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const processRun = async (run) => {
|
|
89
|
+
const { signal } = run.controller;
|
|
90
|
+
try {
|
|
91
|
+
while (!disposed && !signal.aborted && run.queue.length > 0) {
|
|
92
|
+
// Drain everything queued so far into this round: revisions first
|
|
93
|
+
// (bounded workers, no log reads), then a single fold call.
|
|
94
|
+
const pending = run.queue.splice(0, run.queue.length);
|
|
95
|
+
const misses = [];
|
|
96
|
+
let cursor = 0;
|
|
97
|
+
const scanRevisions = async () => {
|
|
98
|
+
for (;;) {
|
|
99
|
+
if (disposed || signal.aborted) return;
|
|
100
|
+
const index = cursor;
|
|
101
|
+
cursor += 1;
|
|
102
|
+
if (index >= pending.length) return;
|
|
103
|
+
const id = pending[index];
|
|
104
|
+
let revision = null;
|
|
105
|
+
if (typeof readRevision === "function") {
|
|
106
|
+
try {
|
|
107
|
+
const observed = await readRevision(id, signal);
|
|
108
|
+
revision = typeof observed === "string" && observed !== "" ? observed : null;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (signal.aborted) throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (disposed || signal.aborted) return;
|
|
114
|
+
const cached = getCachedTitle?.(id, revision);
|
|
115
|
+
if (cached?.hit) run.results.set(id, cached.title);
|
|
116
|
+
else misses.push({ index, id, revision });
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
await Promise.all(Array.from(
|
|
120
|
+
{ length: Math.min(concurrency, pending.length) },
|
|
121
|
+
() => scanRevisions(),
|
|
122
|
+
));
|
|
123
|
+
if (disposed || signal.aborted) break;
|
|
124
|
+
// Worker completion order is not the request order; keep the fold
|
|
125
|
+
// deterministic (and stable for the host's first-occurrence ordering).
|
|
126
|
+
misses.sort((a, b) => a.index - b.index);
|
|
127
|
+
|
|
128
|
+
if (misses.length > 0) {
|
|
129
|
+
let folded;
|
|
130
|
+
try {
|
|
131
|
+
folded = await foldAll(misses.map(({ id }) => id), signal);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (signal.aborted) throw error;
|
|
134
|
+
folded = new Map();
|
|
135
|
+
}
|
|
136
|
+
if (disposed || signal.aborted) break;
|
|
137
|
+
for (const { id, revision } of misses) {
|
|
138
|
+
const result = folded?.get?.(id);
|
|
139
|
+
if (!result?.ok) continue;
|
|
140
|
+
const title = typeof result.title === "string" && result.title !== "" ? result.title : null;
|
|
141
|
+
setCachedTitle?.(id, revision, title);
|
|
142
|
+
run.results.set(id, title);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
onCacheChange();
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// An aborted or faulty refresh is a cache miss, never an endpoint failure.
|
|
149
|
+
} finally {
|
|
150
|
+
finishRun(run);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const addIds = (run, ids) => {
|
|
155
|
+
for (const id of ids) {
|
|
156
|
+
if (typeof id !== "string" || id === "" || run.seen.has(id)) continue;
|
|
157
|
+
run.seen.add(id);
|
|
158
|
+
run.queue.push(id);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const createRun = (ids) => {
|
|
163
|
+
let resolve;
|
|
164
|
+
const promise = new Promise((done) => { resolve = done; });
|
|
165
|
+
const run = {
|
|
166
|
+
controller: new AbortController(),
|
|
167
|
+
queue: [],
|
|
168
|
+
seen: new Set(),
|
|
169
|
+
results: new Map(),
|
|
170
|
+
waiters: 0,
|
|
171
|
+
background: false,
|
|
172
|
+
deadlineAt: Infinity,
|
|
173
|
+
deadlineTimer: null,
|
|
174
|
+
done: false,
|
|
175
|
+
promise,
|
|
176
|
+
resolve,
|
|
177
|
+
};
|
|
178
|
+
addIds(run, ids);
|
|
179
|
+
currentRun = run;
|
|
180
|
+
void processRun(run);
|
|
181
|
+
return run;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const refresh = async (ids, { signal, budgetMs = DEFAULT_TITLE_REFRESH_BUDGET_MS, backgroundOnBudget = true, runDeadlineMs } = {}) => {
|
|
185
|
+
if (disposed || signal?.aborted) return new Map();
|
|
186
|
+
const uniqueIds = [...new Set(ids)].filter((id) => typeof id === "string" && id !== "");
|
|
187
|
+
if (uniqueIds.length === 0) return new Map();
|
|
188
|
+
if (!Number.isFinite(budgetMs) || budgetMs < 0) throw new RangeError("budgetMs must be a non-negative finite number");
|
|
189
|
+
|
|
190
|
+
// A run whose upstream was already aborted can no longer consume its queue:
|
|
191
|
+
// joining it would park this request's ids in a dead queue and hand back a
|
|
192
|
+
// partial result. Retire it and start a live successor instead; the fold
|
|
193
|
+
// gate keeps the successor's work behind the retiring pass's cleanup.
|
|
194
|
+
let run = currentRun;
|
|
195
|
+
if (!run || run.done || run.controller.signal.aborted) run = createRun(uniqueIds);
|
|
196
|
+
else addIds(run, uniqueIds);
|
|
197
|
+
// issue #28 复核 2:run 级截止(取所有声明中**最早**者——只收紧、不放松)。
|
|
198
|
+
// 它不因后来调用者的 background promotion 而失效:到点由 armRunDeadline 退休该 run。
|
|
199
|
+
if (Number.isFinite(runDeadlineMs) && runDeadlineMs >= 0) {
|
|
200
|
+
const nextAt = performance.now() + runDeadlineMs;
|
|
201
|
+
// 只在**收紧**时重设:后来者更晚的声明不得延长期限(issue #28 复核 3)。
|
|
202
|
+
if (nextAt < run.deadlineAt) {
|
|
203
|
+
run.deadlineAt = nextAt;
|
|
204
|
+
// 旧 timer 必须先清掉再重设——armRunDeadline 见到已有 deadlineTimer 会直接返回,
|
|
205
|
+
// 那样「收紧」就静默失效(评审实测:先声明 300ms、后声明 20ms,源仍按 300ms 才取消)。
|
|
206
|
+
if (run.deadlineTimer) {
|
|
207
|
+
clearTimeout(run.deadlineTimer);
|
|
208
|
+
run.deadlineTimer = null;
|
|
209
|
+
}
|
|
210
|
+
armRunDeadline(run);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
run.waiters += 1;
|
|
214
|
+
let released = false;
|
|
215
|
+
const release = () => {
|
|
216
|
+
if (released) return;
|
|
217
|
+
released = true;
|
|
218
|
+
run.waiters -= 1;
|
|
219
|
+
if (!run.done && !run.background && run.waiters === 0) {
|
|
220
|
+
run.controller.abort(new Error("all session-title refresh waiters left"));
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
let timer;
|
|
225
|
+
let abortHandler;
|
|
226
|
+
const interrupted = new Promise((resolve) => {
|
|
227
|
+
if (signal) {
|
|
228
|
+
abortHandler = () => resolve("aborted");
|
|
229
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
230
|
+
}
|
|
231
|
+
timer = setTimeout(() => resolve("budget"), budgetMs);
|
|
232
|
+
});
|
|
233
|
+
const outcome = await Promise.race([
|
|
234
|
+
run.promise.then(() => "done"),
|
|
235
|
+
interrupted,
|
|
236
|
+
]);
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
239
|
+
|
|
240
|
+
// issue #28 复核 BLOCKING 2:调用方可以要求**有限生命期**。默认(响应路径)预算到点会把
|
|
241
|
+
// run 提升为「后台预热」并长期存活;但启动预热**不能**这样——否则一次挂住的预热会让之后
|
|
242
|
+
// 健康的请求一直 join 这个坏 run、只拿到短码。传 `backgroundOnBudget: false` 时不做提升,
|
|
243
|
+
// 于是下面的 release() 在没有其它等待者时会中止该 run(退休),健康请求随后可重新开始。
|
|
244
|
+
if (outcome === "budget" && backgroundOnBudget && !signal?.aborted && !run.done) run.background = true;
|
|
245
|
+
const aborted = signal?.aborted === true;
|
|
246
|
+
release();
|
|
247
|
+
return aborted ? new Map() : new Map(run.results);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
refresh,
|
|
252
|
+
dispose() {
|
|
253
|
+
disposed = true;
|
|
254
|
+
currentRun?.controller.abort(new Error("session-title refresher disposed"));
|
|
255
|
+
},
|
|
256
|
+
/** Test seam: resolve when the current single-flight pass settles. */
|
|
257
|
+
whenIdle() {
|
|
258
|
+
return currentRun?.promise ?? Promise.resolve();
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Host credentials are scoped to the explicit loopback origin and update routes, not to arbitrary release URLs.
|
|
2
|
+
export async function fetchUpdateResource(url, {
|
|
3
|
+
fetchImpl = fetch, token = '', trustedOrigin = '', basePath = '/m', headers = {}, signal, maxRedirects = 5,
|
|
4
|
+
} = {}) {
|
|
5
|
+
// 精确路径集合:同 origin 的其它处理器(如 /other-plugin/api/update/manifest)不得拿到口令。
|
|
6
|
+
const mount = basePath === '/' || basePath === '' ? '/m' : basePath.replace(/\/$/, '');
|
|
7
|
+
const allowedPaths = new Set([`${mount}/api/update/manifest`, `${mount}/api/update/plugin`, `${mount}/api/update/apk`]);
|
|
8
|
+
let target = new URL(url);
|
|
9
|
+
for (let hops = 0; hops <= maxRedirects; hops++) {
|
|
10
|
+
if (target.username || target.password || !(['https:'].includes(target.protocol) ||
|
|
11
|
+
(target.protocol === 'http:' && ['127.0.0.1','localhost','[::1]'].includes(target.hostname)))) throw new Error('update-url-not-allowed');
|
|
12
|
+
const authAllowed = Boolean(token) && trustedOrigin !== '' && target.origin === trustedOrigin &&
|
|
13
|
+
allowedPaths.has(target.pathname);
|
|
14
|
+
const response = await fetchImpl(target.toString(), {
|
|
15
|
+
headers: { ...headers, ...(authAllowed ? { 'x-mobile-token': token } : {}) }, redirect: 'manual', signal,
|
|
16
|
+
});
|
|
17
|
+
if (![301,302,303,307,308].includes(response.status)) return { response, finalUrl: target.toString() };
|
|
18
|
+
const location = response.headers.get('location');
|
|
19
|
+
if (!location) throw new Error('update-redirect-missing');
|
|
20
|
+
await response.body?.cancel?.();
|
|
21
|
+
target = new URL(location, target);
|
|
22
|
+
}
|
|
23
|
+
throw new Error('update-redirect-limit');
|
|
24
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Runs only under an OS flock on a persistent inode. No lock-directory unlink/rename races.
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
function read(file) { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } }
|
|
7
|
+
function atomic(file, value) {
|
|
8
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
9
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
10
|
+
try { writeFileSync(tmp, JSON.stringify(value) + '\n', { mode: 0o600 }); renameSync(tmp, file); }
|
|
11
|
+
finally { if (existsSync(tmp)) rmSync(tmp); }
|
|
12
|
+
}
|
|
13
|
+
function identity(pid) {
|
|
14
|
+
try { const text = readFileSync(`/proc/${pid}/stat`, 'utf8'); return { pid, start: text.slice(text.lastIndexOf(')') + 2).split(' ')[19] }; }
|
|
15
|
+
catch { return { pid, start: null }; }
|
|
16
|
+
}
|
|
17
|
+
function alive(person) {
|
|
18
|
+
if (!person?.pid || !Number.isInteger(person.pid)) return false;
|
|
19
|
+
try { process.kill(person.pid, 0); } catch(e) { return e.code === 'EPERM'; }
|
|
20
|
+
return !person.start || identity(person.pid).start === person.start;
|
|
21
|
+
}
|
|
22
|
+
function owns(holder, lease) { return !!lease?.generation && holder?.generation === lease.generation && holder.opId === lease.opId; }
|
|
23
|
+
function fence(holder, lease) { if (!owns(holder, lease)) throw new Error('lease-lost'); }
|
|
24
|
+
function terminal(phase) { return ['settled', 'rolled-back', 'failed'].includes(phase); }
|
|
25
|
+
|
|
26
|
+
function execute(input) {
|
|
27
|
+
const { lockPath, operation, lease, stateFile } = input;
|
|
28
|
+
const holderFile = join(lockPath, 'holder.json');
|
|
29
|
+
let holder = read(holderFile);
|
|
30
|
+
const available = !holder || ![holder.worker, holder.supervisor].some(alive);
|
|
31
|
+
if (operation === 'acquire') {
|
|
32
|
+
if (holder && owns(holder, lease)) return { ok: true, lease, holder, reentrant: true };
|
|
33
|
+
// Missing publication is not evidence of owner death. Legacy half-locks require manual repair.
|
|
34
|
+
if (existsSync(lockPath) && !existsSync(holderFile)) return { ok: false, error: 'lock-unpublished' };
|
|
35
|
+
if (!available) return { ok: false, error: 'update-busy', holder };
|
|
36
|
+
if (input.stateFile) {
|
|
37
|
+
const tx = read(input.stateFile);
|
|
38
|
+
// 安装/待生效/回滚未完成时,**任何**新操作都不得开始——即使锁已被误释放(holder 为空)。
|
|
39
|
+
if (tx && ['installing','restart-pending','rolling-back'].includes(tx.phase) &&
|
|
40
|
+
(!lease || tx.lease?.generation !== lease.generation)) {
|
|
41
|
+
return { ok: false, error: 'recovery-required', holder, transaction: tx };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
holder = { opId: input.opId, generation: randomUUID(), worker: identity(input.pid), pid: input.pid, at: input.now };
|
|
45
|
+
mkdirSync(lockPath, { recursive: true }); atomic(holderFile, holder);
|
|
46
|
+
return { ok: true, holder, lease: { opId: holder.opId, generation: holder.generation } };
|
|
47
|
+
}
|
|
48
|
+
if (operation === 'release') {
|
|
49
|
+
if (!owns(holder, lease)) return { ok: false, error: 'lease-lost' };
|
|
50
|
+
const tx = input.stateFile ? read(input.stateFile) : null;
|
|
51
|
+
if (tx && tx.lease?.generation === lease.generation && ['installing','restart-pending','rolling-back'].includes(tx.phase)) {
|
|
52
|
+
return { ok: false, error: 'recovery-required' };
|
|
53
|
+
}
|
|
54
|
+
atomic(holderFile, null); return { ok: true };
|
|
55
|
+
}
|
|
56
|
+
if (operation === 'handover') {
|
|
57
|
+
fence(holder, lease);
|
|
58
|
+
holder = { ...holder, supervisor: identity(input.toPid), pid: input.toPid };
|
|
59
|
+
atomic(holderFile, holder); return { ok: true, holder };
|
|
60
|
+
}
|
|
61
|
+
if (operation === 'assert') { fence(holder, lease); return { ok: true, holder }; }
|
|
62
|
+
if (operation === 'state-write') {
|
|
63
|
+
fence(holder, lease);
|
|
64
|
+
if (input.tx.opId !== lease.opId) throw new Error('operation-mismatch');
|
|
65
|
+
const old = read(stateFile);
|
|
66
|
+
if (old?.lease && old.lease.generation !== lease.generation && !terminal(old.phase)) throw new Error('transaction-generation-mismatch');
|
|
67
|
+
// 阶段 CAS:旧 worker 不能把"已被别人推进的阶段"改回去(如 failed → installing)。
|
|
68
|
+
if (old && old.opId === input.tx.opId) {
|
|
69
|
+
if (input.expectedPhases && !input.expectedPhases.includes(old.phase)) throw new Error('phase-cas-failed');
|
|
70
|
+
if (terminal(old.phase) && !terminal(input.tx.phase)) throw new Error('terminal-regression');
|
|
71
|
+
}
|
|
72
|
+
const next = { ...input.tx, lease, id: lease.generation, updatedAt: new Date().toISOString() };
|
|
73
|
+
atomic(stateFile, next);
|
|
74
|
+
if (input.currentFile && input.currentFile !== stateFile) atomic(input.currentFile, next);
|
|
75
|
+
return { ok: true, transaction: next };
|
|
76
|
+
}
|
|
77
|
+
if (operation === 'switch-profile') {
|
|
78
|
+
fence(holder, lease);
|
|
79
|
+
const file = join(input.profileDir, 'package.json'), doc = read(file);
|
|
80
|
+
const key = input.key || 'dsh-mobile-remote';
|
|
81
|
+
const previous = doc?.dependencies?.[key];
|
|
82
|
+
if (typeof previous !== 'string') throw new Error('profile-dependency-missing');
|
|
83
|
+
doc.dependencies[key] = input.specifier; atomic(file, doc);
|
|
84
|
+
return { ok: true, previous, next: input.specifier };
|
|
85
|
+
}
|
|
86
|
+
if (operation === 'restore-profile') {
|
|
87
|
+
fence(holder, lease);
|
|
88
|
+
const file = join(input.profileDir, 'package.json');
|
|
89
|
+
const doc = input.snapshot?.packageJson ? JSON.parse(input.snapshot.packageJson) : read(file);
|
|
90
|
+
if (!doc?.dependencies) throw new Error('profile-dependencies-missing');
|
|
91
|
+
const key = input.key || 'dsh-mobile-remote';
|
|
92
|
+
doc.dependencies[key] = `file:${input.snapshotDir}`; atomic(file, doc);
|
|
93
|
+
const lockfile = join(input.profileDir, 'pnpm-lock.yaml');
|
|
94
|
+
if (typeof input.snapshot?.pnpmLock === 'string') writeFileSync(lockfile, input.snapshot.pnpmLock, { mode: 0o600 });
|
|
95
|
+
else if (input.snapshot?.pnpmLock === null && existsSync(lockfile)) rmSync(lockfile);
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
if (operation === 'publish-snapshot-link') {
|
|
99
|
+
fence(holder, lease);
|
|
100
|
+
const modules = join(input.profileDir, 'node_modules'); mkdirSync(modules, { recursive: true });
|
|
101
|
+
// Never traverse an unexpected node_modules symlink into another profile.
|
|
102
|
+
if (realpathSync(modules) !== join(realpathSync(input.profileDir), 'node_modules')) throw new Error('profile-modules-outside-root');
|
|
103
|
+
const target = join(modules, 'dsh-mobile-remote');
|
|
104
|
+
const staged = `${target}.restore-${randomUUID()}`, previous = `${target}.previous-${randomUUID()}`;
|
|
105
|
+
symlinkSync(realpathSync(input.snapshotDir), staged, 'dir');
|
|
106
|
+
let moved = false;
|
|
107
|
+
try {
|
|
108
|
+
try { renameSync(target, previous); moved = true; } catch(e) { if (e.code !== 'ENOENT') throw e; }
|
|
109
|
+
renameSync(staged, target);
|
|
110
|
+
} catch(e) { if (moved && !existsSync(target)) renameSync(previous, target); throw e; }
|
|
111
|
+
finally { if (existsSync(staged)) rmSync(staged); }
|
|
112
|
+
// Retain the quarantined old link/tree; never delete active hardlinked module files in place.
|
|
113
|
+
return { ok: true, installedRoot: target };
|
|
114
|
+
}
|
|
115
|
+
if (operation === 'claim-recovery') {
|
|
116
|
+
fence(holder, lease);
|
|
117
|
+
const tx = read(stateFile);
|
|
118
|
+
if (!tx || tx.opId !== lease.opId || tx.lease?.generation !== lease.generation) throw new Error('operation-mismatch');
|
|
119
|
+
if (tx.phase !== 'rolling-back') throw new Error('not-recovering');
|
|
120
|
+
// 同代的两个监督器只能有一个执行恢复:先到者写 claim,后到者拿到 claimed。
|
|
121
|
+
if (tx.recoveryClaim && tx.recoveryClaim.owner !== input.owner) return { ok: false, error: 'recovery-claimed', transaction: tx };
|
|
122
|
+
const next = { ...tx, recoveryClaim: { owner: input.owner, at: input.now } };
|
|
123
|
+
atomic(stateFile, next);
|
|
124
|
+
if (input.currentFile && input.currentFile !== stateFile) atomic(input.currentFile, next);
|
|
125
|
+
return { ok: true, transaction: next };
|
|
126
|
+
}
|
|
127
|
+
if (operation === 'finish-terminal') {
|
|
128
|
+
fence(holder, lease);
|
|
129
|
+
const tx = read(stateFile);
|
|
130
|
+
if (!tx || tx.opId !== lease.opId || tx.lease?.generation !== lease.generation) throw new Error('operation-mismatch');
|
|
131
|
+
if (input.expectedPhases && !input.expectedPhases.includes(tx.phase)) throw new Error('phase-cas-failed');
|
|
132
|
+
if (!terminal(input.phase)) throw new Error('not-a-terminal-phase');
|
|
133
|
+
const next = { ...tx, phase: input.phase, result: input.result, lease, id: lease.generation, updatedAt: new Date().toISOString() };
|
|
134
|
+
atomic(stateFile, next);
|
|
135
|
+
if (input.currentFile && input.currentFile !== stateFile) atomic(input.currentFile, next);
|
|
136
|
+
// 发布终态与释放锁是一次仲裁:中间不存在"已终态但锁仍被旧代持有/已被释放"的空窗。
|
|
137
|
+
atomic(holderFile, null);
|
|
138
|
+
return { ok: true, transaction: next };
|
|
139
|
+
}
|
|
140
|
+
if (operation === 'converge') {
|
|
141
|
+
const tx = read(stateFile);
|
|
142
|
+
if (!tx) return { ok: true, action: 'none', transaction: null };
|
|
143
|
+
if (terminal(tx.phase)) return { ok: true, action: 'terminal', transaction: tx };
|
|
144
|
+
if (input.expectedLease && !owns(holder, input.expectedLease)) return { ok: true, action: 'superseded', transaction: tx };
|
|
145
|
+
if (holder && [holder.worker, holder.supervisor].some(alive)) return { ok: true, action: 'active', transaction: tx };
|
|
146
|
+
if (existsSync(lockPath) && !existsSync(holderFile)) return { ok: true, action: 'recent', transaction: tx };
|
|
147
|
+
// Never relabel a possibly damaged install as an ordinary terminal failure.
|
|
148
|
+
if (['installing','restart-pending','rolling-back'].includes(tx.phase)) return { ok: true, action: 'recovery-required', transaction: tx };
|
|
149
|
+
if (input.now - Date.parse(tx.startedAt) < input.staleMs) return { ok: true, action: 'recent', transaction: tx };
|
|
150
|
+
if (tx.lease && holder && !owns(holder, tx.lease)) return { ok: true, action: 'superseded', transaction: tx };
|
|
151
|
+
const next = { ...tx, phase: 'failed', result: { ok: false, reason: 'holder-gone' } };
|
|
152
|
+
atomic(stateFile, next); if (holder) atomic(holderFile, null);
|
|
153
|
+
return { ok: true, action: 'converged', transaction: next };
|
|
154
|
+
}
|
|
155
|
+
throw new Error('unknown-arbitration-operation');
|
|
156
|
+
}
|
|
157
|
+
try { console.log(JSON.stringify(execute(JSON.parse(readFileSync(0, 'utf8'))))); }
|
|
158
|
+
catch(e) { console.log(JSON.stringify({ ok: false, error: e.message })); }
|