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.
Files changed (2) hide show
  1. package/index.js +101 -4
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { appendFileSync, existsSync, readFileSync, statSync, watch } from 'node:fs'
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 hmr.runExclusive(() => hmr.reconcileProfile([]))
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) throw error
194
- await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
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.9",
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",