@raidiant/notifai 0.2.1 → 0.4.0
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/README.md +20 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +85 -6
- package/dist/commands.js +1133 -322
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +312 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +201 -51
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +108 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +11 -9
- package/src/atomic-file.ts +86 -7
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/commands.test.ts +971 -44
- package/src/commands.ts +1323 -337
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +363 -0
- package/src/config.test.ts +6 -5
- package/src/config.ts +85 -16
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +48 -0
- package/src/harnesses.ts +59 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1318 -240
- package/src/hooks.ts +1046 -296
- package/src/install-hooks.test.ts +90 -41
- package/src/install-hooks.ts +259 -74
- package/src/interactive.ts +692 -0
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +220 -48
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +11 -0
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +88 -0
- package/src/ui/config-view.ts +138 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/src/config.ts
CHANGED
|
@@ -4,6 +4,7 @@ import os from 'node:os'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { parse as parseToml } from 'smol-toml'
|
|
6
6
|
import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
|
|
7
|
+
import { HOOK_TIMING } from './harnesses.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Layered configuration with provenance. Most specific wins:
|
|
@@ -39,6 +40,14 @@ export interface ResolvedValue<T> {
|
|
|
39
40
|
type CliSound = (typeof CLI_SOUNDS)[number]
|
|
40
41
|
type CliInterruptionLevel = (typeof INTERRUPTION_LEVELS)[number]
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* How much the local log records. Defined here rather than in `logging.ts`
|
|
45
|
+
* because it is a configuration vocabulary first — `logging.ts` reads config,
|
|
46
|
+
* so the dependency has to point this way.
|
|
47
|
+
*/
|
|
48
|
+
export const LOG_LEVELS = ['off', 'error', 'info', 'debug'] as const
|
|
49
|
+
export type LogLevel = (typeof LOG_LEVELS)[number]
|
|
50
|
+
|
|
42
51
|
export interface CliConfig {
|
|
43
52
|
base_url: ResolvedValue<string>
|
|
44
53
|
wait_seconds: ResolvedValue<number>
|
|
@@ -81,8 +90,8 @@ export interface CliConfig {
|
|
|
81
90
|
away_after_seconds: ResolvedValue<number>
|
|
82
91
|
/**
|
|
83
92
|
* How long a hook blocks waiting for a companion-device answer before falling through
|
|
84
|
-
* to the harness's own prompt.
|
|
85
|
-
*
|
|
93
|
+
* to the harness's own prompt. Runtime arbitration keeps the complete Stop
|
|
94
|
+
* invocation inside its fixed 480s owner budget and 540s adapter ceiling.
|
|
86
95
|
*/
|
|
87
96
|
hook_reply_timeout_seconds: ResolvedValue<number>
|
|
88
97
|
/**
|
|
@@ -96,6 +105,24 @@ export interface CliConfig {
|
|
|
96
105
|
* questions, and answering one should not answer the other.
|
|
97
106
|
*/
|
|
98
107
|
ask_grace_seconds: ResolvedValue<number>
|
|
108
|
+
/**
|
|
109
|
+
* How much of what this CLI does is recorded to the local log.
|
|
110
|
+
*
|
|
111
|
+
* The log exists for agents: a hook runs headless and its most consequential
|
|
112
|
+
* act is usually a decision to do nothing, which leaves no other trace. `off`
|
|
113
|
+
* disables the sink entirely; `debug` adds config resolution and request
|
|
114
|
+
* detail, which is verbose enough to want a reason.
|
|
115
|
+
*/
|
|
116
|
+
log_level: ResolvedValue<LogLevel>
|
|
117
|
+
/**
|
|
118
|
+
* Bytes the active log file may reach before it is rotated. With
|
|
119
|
+
* `log_max_files` this is the whole disk budget — nothing here grows without
|
|
120
|
+
* a ceiling, because a log that fills a disk is a bug that outlives its
|
|
121
|
+
* usefulness.
|
|
122
|
+
*/
|
|
123
|
+
log_max_bytes: ResolvedValue<number>
|
|
124
|
+
/** Log files kept in total, the active one included. */
|
|
125
|
+
log_max_files: ResolvedValue<number>
|
|
99
126
|
}
|
|
100
127
|
|
|
101
128
|
export const CONFIG_KEYS = [
|
|
@@ -113,6 +140,9 @@ export const CONFIG_KEYS = [
|
|
|
113
140
|
'away_after_seconds',
|
|
114
141
|
'hook_reply_timeout_seconds',
|
|
115
142
|
'ask_grace_seconds',
|
|
143
|
+
'log_level',
|
|
144
|
+
'log_max_bytes',
|
|
145
|
+
'log_max_files',
|
|
116
146
|
] as const
|
|
117
147
|
export type ConfigKey = (typeof CONFIG_KEYS)[number]
|
|
118
148
|
|
|
@@ -129,8 +159,21 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
|
|
|
129
159
|
'away_after_seconds',
|
|
130
160
|
'hook_reply_timeout_seconds',
|
|
131
161
|
'ask_grace_seconds',
|
|
162
|
+
'log_max_bytes',
|
|
163
|
+
'log_max_files',
|
|
132
164
|
]
|
|
133
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Keys whose value must be one of a fixed set.
|
|
168
|
+
*
|
|
169
|
+
* Config is readable from a repository, so an unrecognised value is untrusted
|
|
170
|
+
* input rather than a typo — `coerce` drops it and the layer below wins, which
|
|
171
|
+
* is the same discipline the numeric bounds follow.
|
|
172
|
+
*/
|
|
173
|
+
export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
|
|
174
|
+
log_level: LOG_LEVELS,
|
|
175
|
+
}
|
|
176
|
+
|
|
134
177
|
const DEFAULTS = {
|
|
135
178
|
base_url: 'https://notifai.fly.dev',
|
|
136
179
|
wait_seconds: 10,
|
|
@@ -144,17 +187,29 @@ const DEFAULTS = {
|
|
|
144
187
|
// Installing the hooks is the opt-in; these exist to switch the behaviour
|
|
145
188
|
// back off for a project or a session without uninstalling anything.
|
|
146
189
|
ask_notifications: true,
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
require_idle: true,
|
|
190
|
+
// Questions reach devices immediately by default. Both a terminal-first
|
|
191
|
+
// grace and presence gating remain independent opt-in controls.
|
|
192
|
+
require_idle: false,
|
|
151
193
|
away_after_seconds: 120,
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
194
|
+
// Runtime waits stay inside a 480s budget. Harnesses that require a declared
|
|
195
|
+
// Stop limit use a preference-stable 540s; Codex uses its host-owned Stop
|
|
196
|
+
// default. Neither definition changes when these preferences do.
|
|
197
|
+
hook_reply_timeout_seconds: 480,
|
|
198
|
+
ask_grace_seconds: 0,
|
|
199
|
+
// On by default: the log is the only account of what a headless hook did, and
|
|
200
|
+
// a diagnostic nobody switched on before the thing went wrong is not one.
|
|
201
|
+
log_level: 'info',
|
|
202
|
+
// 2 MB × 3 files caps the logs at roughly 6 MB, which is weeks of ordinary
|
|
203
|
+
// agent traffic and small enough that nobody has to think about it.
|
|
204
|
+
log_max_bytes: 2_000_000,
|
|
205
|
+
log_max_files: 3,
|
|
156
206
|
} satisfies Record<ConfigKey, unknown>
|
|
157
207
|
|
|
208
|
+
/** The shipped value beneath every user-controlled configuration layer. */
|
|
209
|
+
export function configDefaultValue(key: ConfigKey): unknown {
|
|
210
|
+
return DEFAULTS[key]
|
|
211
|
+
}
|
|
212
|
+
|
|
158
213
|
export function globalConfigDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
159
214
|
const xdg = env['XDG_CONFIG_HOME']
|
|
160
215
|
const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.config')
|
|
@@ -245,17 +300,24 @@ export function findProjectLocalConfigPath(startDir: string): string | null {
|
|
|
245
300
|
* Ranges every layer is clamped to. Config is readable from a repository, so an
|
|
246
301
|
* out-of-range value is attacker input, not a typo: `away_after_seconds = -1`
|
|
247
302
|
* would make a user who just typed count as absent, and an oversized reply
|
|
248
|
-
* timeout would
|
|
249
|
-
*
|
|
303
|
+
* timeout would exhaust the hook process budget and kill it after the user has
|
|
304
|
+
* already answered.
|
|
250
305
|
*/
|
|
251
306
|
const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> = {
|
|
252
307
|
wait_seconds: { min: 0, max: 300 },
|
|
253
308
|
ttl_seconds: { min: 0, max: 7 * 24 * 3600 },
|
|
254
309
|
away_after_seconds: { min: 5, max: 24 * 3600 },
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
310
|
+
// The runtime clamps grace + reply work below the host process ceiling; this
|
|
311
|
+
// maximum is never copied into the installed definition.
|
|
312
|
+
// The wire protocol's minimum reply window is 60s; matching it prevents the
|
|
313
|
+
// server from accepting an answer after a shorter local wait has returned.
|
|
314
|
+
hook_reply_timeout_seconds: { min: 60, max: 540 },
|
|
315
|
+
ask_grace_seconds: { min: 0, max: HOOK_TIMING.maxGraceSeconds },
|
|
316
|
+
// The floor keeps rotation from thrashing (a file smaller than a handful of
|
|
317
|
+
// records would rotate on nearly every write); the ceiling is the point past
|
|
318
|
+
// which "local diagnostics" has become "a database on your disk".
|
|
319
|
+
log_max_bytes: { min: 64_000, max: 100_000_000 },
|
|
320
|
+
log_max_files: { min: 1, max: 20 },
|
|
259
321
|
}
|
|
260
322
|
|
|
261
323
|
/** Drops values a layer must not be trusted to supply, rather than throwing. */
|
|
@@ -266,6 +328,10 @@ function coerce(key: ConfigKey, value: unknown): unknown | undefined {
|
|
|
266
328
|
return Math.min(bounds.max, Math.max(bounds.min, Math.trunc(value)))
|
|
267
329
|
}
|
|
268
330
|
if (BOOLEAN_CONFIG_KEYS.includes(key)) return typeof value === 'boolean' ? value : undefined
|
|
331
|
+
const allowed = ENUM_CONFIG_VALUES[key]
|
|
332
|
+
if (allowed !== undefined) {
|
|
333
|
+
return typeof value === 'string' && allowed.includes(value) ? value : undefined
|
|
334
|
+
}
|
|
269
335
|
return value
|
|
270
336
|
}
|
|
271
337
|
|
|
@@ -343,5 +409,8 @@ export function loadConfig(options: {
|
|
|
343
409
|
away_after_seconds: resolve('away_after_seconds'),
|
|
344
410
|
hook_reply_timeout_seconds: resolve('hook_reply_timeout_seconds'),
|
|
345
411
|
ask_grace_seconds: resolve('ask_grace_seconds'),
|
|
412
|
+
log_level: resolve('log_level'),
|
|
413
|
+
log_max_bytes: resolve('log_max_bytes'),
|
|
414
|
+
log_max_files: resolve('log_max_files'),
|
|
346
415
|
}
|
|
347
416
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { withFileLock, type FileLockObservation } from './file-lock.js'
|
|
4
|
+
|
|
5
|
+
const worker = process.env['NOTIFAI_FILE_LOCK_WORKER']
|
|
6
|
+
const sleep = new Int32Array(new SharedArrayBuffer(4))
|
|
7
|
+
|
|
8
|
+
function waitFor(file: string): void {
|
|
9
|
+
const deadline = Date.now() + 15_000
|
|
10
|
+
while (!existsSync(file)) {
|
|
11
|
+
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${file}`)
|
|
12
|
+
Atomics.wait(sleep, 0, 0, 2)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (worker === undefined) {
|
|
17
|
+
describe('file lock process worker', () => {
|
|
18
|
+
it('stays dormant outside the cross-process regressions', () => {
|
|
19
|
+
expect(worker).toBeUndefined()
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
} else {
|
|
23
|
+
describe('file lock process worker', () => {
|
|
24
|
+
it('runs one instrumented lock contender', () => {
|
|
25
|
+
const pauseAt = process.env['NOTIFAI_FILE_LOCK_PAUSE_AT']
|
|
26
|
+
const pausePath = process.env['NOTIFAI_FILE_LOCK_PAUSED']!
|
|
27
|
+
const continuePath = process.env['NOTIFAI_FILE_LOCK_CONTINUE']!
|
|
28
|
+
const enteredPath = process.env['NOTIFAI_FILE_LOCK_ENTERED']!
|
|
29
|
+
const releasePath = process.env['NOTIFAI_FILE_LOCK_RELEASE']!
|
|
30
|
+
const waitPath = process.env['NOTIFAI_FILE_LOCK_WAITS']!
|
|
31
|
+
let waits = 0
|
|
32
|
+
|
|
33
|
+
withFileLock(
|
|
34
|
+
process.env['NOTIFAI_FILE_LOCK_PATH']!,
|
|
35
|
+
() => {
|
|
36
|
+
writeFileSync(enteredPath, worker)
|
|
37
|
+
if (process.env['NOTIFAI_FILE_LOCK_ACTION'] === 'crash') {
|
|
38
|
+
process.kill(process.pid, 'SIGKILL')
|
|
39
|
+
throw new Error('SIGKILL did not terminate the worker')
|
|
40
|
+
}
|
|
41
|
+
waitFor(releasePath)
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
waitMs: 15_000,
|
|
45
|
+
observe(observation: FileLockObservation) {
|
|
46
|
+
if (
|
|
47
|
+
observation.phase === 'choosing-published' ||
|
|
48
|
+
observation.phase === 'stale-entry'
|
|
49
|
+
) {
|
|
50
|
+
writeFileSync(pausePath, observation.entry)
|
|
51
|
+
if (pauseAt === observation.phase) waitFor(continuePath)
|
|
52
|
+
}
|
|
53
|
+
if (observation.phase === 'waiting') {
|
|
54
|
+
waits += 1
|
|
55
|
+
writeFileSync(waitPath, String(waits))
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(readFileSync(enteredPath, 'utf8')).toBe(worker)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
symlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import { withFileLock, withTargetFileLock } from './file-lock.js'
|
|
17
|
+
|
|
18
|
+
interface LockWorker {
|
|
19
|
+
child: ChildProcess
|
|
20
|
+
done: Promise<void>
|
|
21
|
+
pausedPath: string
|
|
22
|
+
continuePath: string
|
|
23
|
+
enteredPath: string
|
|
24
|
+
releasePath: string
|
|
25
|
+
waitsPath: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface LockWorkerOptions {
|
|
29
|
+
pauseAt?: string
|
|
30
|
+
action?: 'hold' | 'crash'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function waitCount(file: string): number {
|
|
34
|
+
try {
|
|
35
|
+
return Number(readFileSync(file, 'utf8'))
|
|
36
|
+
} catch {
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function waitUntil(predicate: () => boolean, timeoutMs = 10_000): Promise<void> {
|
|
42
|
+
const deadline = Date.now() + timeoutMs
|
|
43
|
+
while (!predicate()) {
|
|
44
|
+
if (Date.now() >= deadline) throw new Error('timed out waiting for file-lock worker')
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lockWorker(
|
|
50
|
+
root: string,
|
|
51
|
+
lockPath: string,
|
|
52
|
+
id: string,
|
|
53
|
+
options: LockWorkerOptions = {},
|
|
54
|
+
): LockWorker {
|
|
55
|
+
const marker = (name: string): string => path.join(root, `${id}-${name}`)
|
|
56
|
+
const pausedPath = marker('paused')
|
|
57
|
+
const continuePath = marker('continue')
|
|
58
|
+
const enteredPath = marker('entered')
|
|
59
|
+
const releasePath = marker('release')
|
|
60
|
+
const waitsPath = marker('waits')
|
|
61
|
+
const vitest = path.join(process.cwd(), 'node_modules', 'vitest', 'vitest.mjs')
|
|
62
|
+
const child = spawn(
|
|
63
|
+
process.execPath,
|
|
64
|
+
[vitest, 'run', 'src/file-lock-process-worker.test.ts', '--reporter=dot', '--maxWorkers=1'],
|
|
65
|
+
{
|
|
66
|
+
cwd: process.cwd(),
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
NOTIFAI_FILE_LOCK_WORKER: id,
|
|
70
|
+
NOTIFAI_FILE_LOCK_PATH: lockPath,
|
|
71
|
+
NOTIFAI_FILE_LOCK_PAUSED: pausedPath,
|
|
72
|
+
NOTIFAI_FILE_LOCK_CONTINUE: continuePath,
|
|
73
|
+
NOTIFAI_FILE_LOCK_ENTERED: enteredPath,
|
|
74
|
+
NOTIFAI_FILE_LOCK_RELEASE: releasePath,
|
|
75
|
+
NOTIFAI_FILE_LOCK_WAITS: waitsPath,
|
|
76
|
+
NOTIFAI_FILE_LOCK_ACTION: options.action ?? 'hold',
|
|
77
|
+
...(options.pauseAt === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { NOTIFAI_FILE_LOCK_PAUSE_AT: options.pauseAt }),
|
|
80
|
+
},
|
|
81
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
const stdout: Buffer[] = []
|
|
85
|
+
const stderr: Buffer[] = []
|
|
86
|
+
child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
|
|
87
|
+
child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
|
|
88
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
89
|
+
child.once('error', reject)
|
|
90
|
+
child.once('exit', (code, signal) => {
|
|
91
|
+
const expectedExit =
|
|
92
|
+
options.action === 'crash' ? code !== 0 || signal !== null : code === 0
|
|
93
|
+
if (expectedExit) resolve()
|
|
94
|
+
else {
|
|
95
|
+
reject(
|
|
96
|
+
new Error(
|
|
97
|
+
`file-lock worker ${id} exited ${String(code)}\n${Buffer.concat(stdout).toString()}\n${Buffer.concat(stderr).toString()}`,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
void done.catch(() => undefined)
|
|
104
|
+
return { child, done, pausedPath, continuePath, enteredPath, releasePath, waitsPath }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stop(workers: LockWorker[]): void {
|
|
108
|
+
for (const worker of workers) {
|
|
109
|
+
if (worker.child.exitCode === null && worker.child.signalCode === null) worker.child.kill('SIGKILL')
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('cross-process file lock', () => {
|
|
114
|
+
it('does not expose an ownerless publication window before acquisition is complete', async () => {
|
|
115
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-file-lock-'))
|
|
116
|
+
const lockPath = path.join(root, 'shared.lock')
|
|
117
|
+
const workers: LockWorker[] = []
|
|
118
|
+
try {
|
|
119
|
+
const first = lockWorker(root, lockPath, 'first', { pauseAt: 'choosing-published' })
|
|
120
|
+
workers.push(first)
|
|
121
|
+
await waitUntil(() => existsSync(first.pausedPath))
|
|
122
|
+
|
|
123
|
+
const published = readdirSync(lockPath)
|
|
124
|
+
expect(published).toHaveLength(1)
|
|
125
|
+
expect(published[0]).toMatch(/^choosing-\d+-[0-9a-f]+$/)
|
|
126
|
+
expect(statSync(path.join(lockPath, published[0]!)).size).toBe(0)
|
|
127
|
+
|
|
128
|
+
const second = lockWorker(root, lockPath, 'second')
|
|
129
|
+
workers.push(second)
|
|
130
|
+
await waitUntil(() => existsSync(second.pausedPath) && waitCount(second.waitsPath) > 0)
|
|
131
|
+
expect(existsSync(first.enteredPath)).toBe(false)
|
|
132
|
+
expect(existsSync(second.enteredPath)).toBe(false)
|
|
133
|
+
|
|
134
|
+
writeFileSync(first.continuePath, 'continue')
|
|
135
|
+
await waitUntil(() => existsSync(first.enteredPath) !== existsSync(second.enteredPath))
|
|
136
|
+
|
|
137
|
+
const winner = existsSync(first.enteredPath) ? first : second
|
|
138
|
+
const loser = winner === first ? second : first
|
|
139
|
+
const waitsBefore = waitCount(loser.waitsPath)
|
|
140
|
+
await waitUntil(() => {
|
|
141
|
+
if (existsSync(loser.enteredPath)) throw new Error('both lock actions overlapped')
|
|
142
|
+
return waitCount(loser.waitsPath) > waitsBefore
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
writeFileSync(winner.releasePath, 'release')
|
|
146
|
+
await waitUntil(() => existsSync(loser.enteredPath))
|
|
147
|
+
writeFileSync(loser.releasePath, 'release')
|
|
148
|
+
await Promise.all(workers.map(({ done }) => done))
|
|
149
|
+
expect(existsSync(lockPath)).toBe(false)
|
|
150
|
+
} finally {
|
|
151
|
+
stop(workers)
|
|
152
|
+
await Promise.allSettled(workers.map(({ done }) => done))
|
|
153
|
+
rmSync(root, { recursive: true, force: true })
|
|
154
|
+
}
|
|
155
|
+
}, 30_000)
|
|
156
|
+
|
|
157
|
+
it('cannot reap a successor after observing a dead owner', async () => {
|
|
158
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-file-lock-'))
|
|
159
|
+
const lockPath = path.join(root, 'shared.lock')
|
|
160
|
+
const workers: LockWorker[] = []
|
|
161
|
+
try {
|
|
162
|
+
const crashed = lockWorker(root, lockPath, 'crashed', { action: 'crash' })
|
|
163
|
+
workers.push(crashed)
|
|
164
|
+
await waitUntil(() => existsSync(crashed.enteredPath))
|
|
165
|
+
await crashed.done
|
|
166
|
+
|
|
167
|
+
const staleEntries = readdirSync(lockPath)
|
|
168
|
+
expect(staleEntries).toHaveLength(1)
|
|
169
|
+
expect(staleEntries[0]).toMatch(/^ticket-\d+-\d+-[0-9a-f]+$/)
|
|
170
|
+
const staleEntry = staleEntries[0]!
|
|
171
|
+
|
|
172
|
+
const delayedReaper = lockWorker(root, lockPath, 'delayed-reaper', {
|
|
173
|
+
pauseAt: 'stale-entry',
|
|
174
|
+
})
|
|
175
|
+
workers.push(delayedReaper)
|
|
176
|
+
await waitUntil(
|
|
177
|
+
() =>
|
|
178
|
+
existsSync(delayedReaper.pausedPath) &&
|
|
179
|
+
readFileSync(delayedReaper.pausedPath, 'utf8') === staleEntry,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const successor = lockWorker(root, lockPath, 'successor')
|
|
183
|
+
workers.push(successor)
|
|
184
|
+
await waitUntil(() => existsSync(successor.enteredPath))
|
|
185
|
+
const successorEntry = readdirSync(lockPath).find((entry) => entry.startsWith('ticket-'))
|
|
186
|
+
expect(successorEntry).toBeDefined()
|
|
187
|
+
expect(successorEntry).not.toBe(staleEntry)
|
|
188
|
+
|
|
189
|
+
writeFileSync(delayedReaper.continuePath, 'continue')
|
|
190
|
+
await waitUntil(() => {
|
|
191
|
+
if (existsSync(delayedReaper.enteredPath)) {
|
|
192
|
+
throw new Error('the delayed stale cleanup displaced the successor')
|
|
193
|
+
}
|
|
194
|
+
return waitCount(delayedReaper.waitsPath) > 0
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
writeFileSync(successor.releasePath, 'release')
|
|
198
|
+
await waitUntil(() => existsSync(delayedReaper.enteredPath))
|
|
199
|
+
writeFileSync(delayedReaper.releasePath, 'release')
|
|
200
|
+
await Promise.all([successor.done, delayedReaper.done])
|
|
201
|
+
} finally {
|
|
202
|
+
stop(workers)
|
|
203
|
+
await Promise.allSettled(workers.map(({ done }) => done))
|
|
204
|
+
rmSync(root, { recursive: true, force: true })
|
|
205
|
+
}
|
|
206
|
+
}, 30_000)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('target file transaction lock', () => {
|
|
210
|
+
it('refuses a symlinked target parent before publishing a contender', () => {
|
|
211
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
|
212
|
+
const foreign = path.join(root, 'foreign')
|
|
213
|
+
const linked = path.join(root, 'linked')
|
|
214
|
+
mkdirSync(foreign)
|
|
215
|
+
symlinkSync(foreign, linked)
|
|
216
|
+
|
|
217
|
+
expect(() => withTargetFileLock(path.join(linked, 'settings.json'), () => {})).toThrow(
|
|
218
|
+
/not a regular directory/,
|
|
219
|
+
)
|
|
220
|
+
expect(existsSync(path.join(foreign, '.settings.json.notifai.lock'))).toBe(false)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('refuses a symlink posing as the lock rendezvous', () => {
|
|
224
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
|
225
|
+
const foreign = path.join(root, 'foreign')
|
|
226
|
+
const lock = path.join(root, '.settings.json.notifai.lock')
|
|
227
|
+
mkdirSync(foreign)
|
|
228
|
+
symlinkSync(foreign, lock)
|
|
229
|
+
|
|
230
|
+
expect(() => withTargetFileLock(path.join(root, 'settings.json'), () => {})).toThrow(
|
|
231
|
+
/not a regular directory/,
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('does not remove a replacement at its unique owned entry', () => {
|
|
236
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
|
237
|
+
const target = path.join(root, 'settings.json')
|
|
238
|
+
const lock = path.join(root, '.settings.json.notifai.lock')
|
|
239
|
+
let replacement = ''
|
|
240
|
+
|
|
241
|
+
expect(() =>
|
|
242
|
+
withTargetFileLock(target, () => {
|
|
243
|
+
const entry = readdirSync(lock)[0]!
|
|
244
|
+
replacement = path.join(lock, entry)
|
|
245
|
+
rmSync(replacement)
|
|
246
|
+
writeFileSync(replacement, 'replacement')
|
|
247
|
+
}),
|
|
248
|
+
).toThrow(/changed while held/)
|
|
249
|
+
expect(readFileSync(replacement, 'utf8')).toBe('replacement')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('preserves the action error when release also finds a replacement', () => {
|
|
253
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
|
254
|
+
const target = path.join(root, 'settings.json')
|
|
255
|
+
const lock = path.join(root, '.settings.json.notifai.lock')
|
|
256
|
+
const actionError = new Error('merge failed')
|
|
257
|
+
|
|
258
|
+
expect(() =>
|
|
259
|
+
withTargetFileLock(target, () => {
|
|
260
|
+
const entry = path.join(lock, readdirSync(lock)[0]!)
|
|
261
|
+
rmSync(entry)
|
|
262
|
+
writeFileSync(entry, 'replacement')
|
|
263
|
+
throw actionError
|
|
264
|
+
}),
|
|
265
|
+
).toThrow(actionError)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('refuses a symlink masquerading as a protocol entry', () => {
|
|
269
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
|
270
|
+
const lock = path.join(root, 'shared.lock')
|
|
271
|
+
const foreign = path.join(root, 'foreign')
|
|
272
|
+
mkdirSync(lock)
|
|
273
|
+
writeFileSync(foreign, 'leave me')
|
|
274
|
+
symlinkSync(foreign, path.join(lock, 'ticket-1-999999-deadbeef'))
|
|
275
|
+
|
|
276
|
+
expect(() => withFileLock(lock, () => {})).toThrow(/regular file-lock entry/)
|
|
277
|
+
expect(readFileSync(foreign, 'utf8')).toBe('leave me')
|
|
278
|
+
})
|
|
279
|
+
})
|