dsh-unlag 1.7.9 → 1.7.11
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/index.js +101 -4
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, statSync, watch, writeFileSync } from 'node:fs'
|
|
2
3
|
import { homedir } from 'node:os'
|
|
3
4
|
import { join, resolve } from 'node:path'
|
|
4
5
|
|
|
@@ -26,6 +27,16 @@ export const name = 'dsh-unlag'
|
|
|
26
27
|
*/
|
|
27
28
|
export const inject = ['webServer']
|
|
28
29
|
|
|
30
|
+
/** A transaction that has not finished in this long is stuck; the press reports it instead of hanging. */
|
|
31
|
+
const RELOAD_TIMEOUT_MS = 20_000
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A saved file reaches the app through the reload service's transaction context. If a later version
|
|
35
|
+
* of the app stops exposing that context, the queue cannot be entered from a save; the profile's
|
|
36
|
+
* patch file is used instead, because the loader watches it and re-imports the modules that changed.
|
|
37
|
+
*/
|
|
38
|
+
const PROFILE_MARKER = '# dsh-unlag: apply saved plugin code'
|
|
39
|
+
|
|
29
40
|
const LOG_PATH = join(homedir(), '.dsh', 'unlag-reload.log')
|
|
30
41
|
/** Tests point this at a scratch file, so a test run leaves the live log readable. */
|
|
31
42
|
const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
|
|
@@ -144,6 +155,66 @@ function worthReloading(filename) {
|
|
|
144
155
|
* The plugin half. One controller serves both callers: a saved file (debounced) and the header
|
|
145
156
|
* button (immediate). A swap never waits for turns, never interrupts them, and never re-enters.
|
|
146
157
|
*/
|
|
158
|
+
/**
|
|
159
|
+
* The service keeps its transaction context in an AsyncLocalStorage on the instance. Resolve it by
|
|
160
|
+
* type as well as by name, so a renamed field still works; the name stays the fast path.
|
|
161
|
+
*/
|
|
162
|
+
function transactionContext(service) {
|
|
163
|
+
try {
|
|
164
|
+
if (typeof service?.executing?.exit === 'function') return service.executing
|
|
165
|
+
for (const value of Object.values(service ?? {})) if (value instanceof AsyncLocalStorage) return value
|
|
166
|
+
} catch {}
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The patch file of the profile this plugin runs in, when the loader exposes the profile. */
|
|
171
|
+
function profilePatch(ctx) {
|
|
172
|
+
try {
|
|
173
|
+
const dir = ctx.get('profileContext')?.dir
|
|
174
|
+
if (typeof dir !== 'string' || dir === '') return null
|
|
175
|
+
const file = join(dir, 'cordis.patch.yml')
|
|
176
|
+
return existsSync(file) ? file : null
|
|
177
|
+
} catch {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Drop the marker line again without touching anything else the file holds. */
|
|
183
|
+
function clearProfileMarker(file) {
|
|
184
|
+
try {
|
|
185
|
+
const text = readFileSync(file, 'utf8')
|
|
186
|
+
if (!text.includes(PROFILE_MARKER)) return
|
|
187
|
+
const withoutBlankLine = text.replace(`\n\n${PROFILE_MARKER}\n`, '\n')
|
|
188
|
+
writeFileSync(file, withoutBlankLine.replace(`\n${PROFILE_MARKER}\n`, '').replace(PROFILE_MARKER, ''))
|
|
189
|
+
} catch (error) {
|
|
190
|
+
log(`a reload marker is still in the profile: ${error?.message ?? error}`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Last resort when a save cannot be queued: one comment line in the profile's patch file makes the
|
|
196
|
+
* loader re-import the modules that changed, the same outcome a queued reload gives. The line is
|
|
197
|
+
* taken back out as soon as the loader has read it, and a line a crash leaves behind is removed at
|
|
198
|
+
* the next start, so the profile is left as the user wrote it.
|
|
199
|
+
* @returns whether the profile was asked, so the caller can say why the save did not apply.
|
|
200
|
+
*/
|
|
201
|
+
function markProfileForReload(ctx) {
|
|
202
|
+
const file = profilePatch(ctx)
|
|
203
|
+
if (file === null) return false
|
|
204
|
+
try {
|
|
205
|
+
const text = readFileSync(file, 'utf8')
|
|
206
|
+
if (text.includes(PROFILE_MARKER)) return false
|
|
207
|
+
appendFileSync(file, `\n${PROFILE_MARKER}\n`)
|
|
208
|
+
} catch (error) {
|
|
209
|
+
log(`the profile could not be asked for a reload: ${error?.message ?? error}`)
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
log('the saved plugin code is applied by a profile reload instead')
|
|
213
|
+
const timer = setTimeout(() => clearProfileMarker(file), 3000)
|
|
214
|
+
timer.unref?.()
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
|
|
147
218
|
function pluginReloader(ctx, config) {
|
|
148
219
|
/** HMR can start after this half, so ask per use instead of once at load. */
|
|
149
220
|
const hmrService = () => {
|
|
@@ -152,6 +223,9 @@ function pluginReloader(ctx, config) {
|
|
|
152
223
|
return hmr
|
|
153
224
|
}
|
|
154
225
|
const available = () => hmrService() !== null
|
|
226
|
+
/** A marker line this plugin left in the profile is not the user's; take it back out at start. */
|
|
227
|
+
const leftover = profilePatch(ctx)
|
|
228
|
+
if (leftover !== null) clearProfileMarker(leftover)
|
|
155
229
|
/** Opt-in: wait for a quiet moment instead of swapping at once. Off by default. */
|
|
156
230
|
const idleOnly = config?.idleOnly === true
|
|
157
231
|
const debounce = Number.isFinite(config?.debounce) ? config.debounce : 300
|
|
@@ -182,16 +256,39 @@ function pluginReloader(ctx, config) {
|
|
|
182
256
|
* @returns what the service reported, so the caller can tell done from partly done.
|
|
183
257
|
*/
|
|
184
258
|
const reload = async (hmr) => {
|
|
259
|
+
/**
|
|
260
|
+
* Queue the recomposition through the service's own queue. A watcher callback created while a
|
|
261
|
+
* reload was restarting this plugin inherits that reload's context, and the service reads any
|
|
262
|
+
* request made from that context as a nested one even though the transaction is over. Leaving
|
|
263
|
+
* that context first is what the service's own watcher does, and it keeps the request queued
|
|
264
|
+
* instead of running a second recomposition beside the first.
|
|
265
|
+
*/
|
|
266
|
+
const context = transactionContext(hmr)
|
|
267
|
+
const ask = (service) =>
|
|
268
|
+
context === null
|
|
269
|
+
? service.runExclusive(() => service.reconcileProfile([]))
|
|
270
|
+
: context.exit(() => service.runExclusive(() => service.reconcileProfile([])))
|
|
271
|
+
const abandoned = () =>
|
|
272
|
+
new Promise((_resolve, reject) => {
|
|
273
|
+
const limit = Number(process.env.DSH_UNLAG_TIMEOUT_MS ?? '') || RELOAD_TIMEOUT_MS
|
|
274
|
+
const timer = setTimeout(() => reject(new Error('the plugin update did not finish in time')), limit)
|
|
275
|
+
timer.unref?.()
|
|
276
|
+
})
|
|
185
277
|
let result = null
|
|
186
278
|
for (let attempt = 0; ; attempt += 1) {
|
|
187
279
|
try {
|
|
188
|
-
result = await
|
|
280
|
+
result = await Promise.race([ask(hmr), abandoned()])
|
|
189
281
|
break
|
|
190
282
|
} catch (error) {
|
|
191
283
|
const message = String(error?.message ?? error)
|
|
192
284
|
if (message.includes('nested') === false) throw error
|
|
193
|
-
if (attempt >= 9)
|
|
194
|
-
|
|
285
|
+
if (attempt >= 9) {
|
|
286
|
+
/** No context to leave means a save cannot be queued at all: hand the file to the loader. */
|
|
287
|
+
if (context === null) markProfileForReload(ctx)
|
|
288
|
+
throw error
|
|
289
|
+
}
|
|
290
|
+
const step = Number(process.env.DSH_UNLAG_RETRY_MS ?? '') || 250
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, step * (attempt + 1)))
|
|
195
292
|
}
|
|
196
293
|
}
|
|
197
294
|
const warnings = Array.isArray(result?.warnings) ? result.warnings : []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unlag",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "One press applies plugin edits right away and brings a laggy interface back; running turns, queued messages and unsent input are untouched, and nothing restarts.",
|
|
6
6
|
"license": "MIT",
|