dsh-unlag 1.7.10 → 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 +83 -6
  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
 
@@ -29,6 +30,13 @@ export const inject = ['webServer']
29
30
  /** A transaction that has not finished in this long is stuck; the press reports it instead of hanging. */
30
31
  const RELOAD_TIMEOUT_MS = 20_000
31
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
+
32
40
  const LOG_PATH = join(homedir(), '.dsh', 'unlag-reload.log')
33
41
  /** Tests point this at a scratch file, so a test run leaves the live log readable. */
34
42
  const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
@@ -147,6 +155,66 @@ function worthReloading(filename) {
147
155
  * The plugin half. One controller serves both callers: a saved file (debounced) and the header
148
156
  * button (immediate). A swap never waits for turns, never interrupts them, and never re-enters.
149
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
+
150
218
  function pluginReloader(ctx, config) {
151
219
  /** HMR can start after this half, so ask per use instead of once at load. */
152
220
  const hmrService = () => {
@@ -155,6 +223,9 @@ function pluginReloader(ctx, config) {
155
223
  return hmr
156
224
  }
157
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)
158
229
  /** Opt-in: wait for a quiet moment instead of swapping at once. Off by default. */
159
230
  const idleOnly = config?.idleOnly === true
160
231
  const debounce = Number.isFinite(config?.debounce) ? config.debounce : 300
@@ -192,10 +263,11 @@ function pluginReloader(ctx, config) {
192
263
  * that context first is what the service's own watcher does, and it keeps the request queued
193
264
  * instead of running a second recomposition beside the first.
194
265
  */
266
+ const context = transactionContext(hmr)
195
267
  const ask = (service) =>
196
- typeof service.executing?.exit === 'function'
197
- ? service.executing.exit(() => service.runExclusive(() => service.reconcileProfile([])))
198
- : service.runExclusive(() => service.reconcileProfile([]))
268
+ context === null
269
+ ? service.runExclusive(() => service.reconcileProfile([]))
270
+ : context.exit(() => service.runExclusive(() => service.reconcileProfile([])))
199
271
  const abandoned = () =>
200
272
  new Promise((_resolve, reject) => {
201
273
  const limit = Number(process.env.DSH_UNLAG_TIMEOUT_MS ?? '') || RELOAD_TIMEOUT_MS
@@ -210,8 +282,13 @@ function pluginReloader(ctx, config) {
210
282
  } catch (error) {
211
283
  const message = String(error?.message ?? error)
212
284
  if (message.includes('nested') === false) throw error
213
- if (attempt >= 9) throw error
214
- 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)))
215
292
  }
216
293
  }
217
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.10",
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",