redmine-context 1.0.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/LICENSE +21 -0
- package/README.md +449 -0
- package/dist/bundle/index.d.ts +5 -0
- package/dist/bundle/index.js +5 -0
- package/dist/bundle/json.d.ts +90 -0
- package/dist/bundle/json.js +266 -0
- package/dist/bundle/markdown.d.ts +75 -0
- package/dist/bundle/markdown.js +294 -0
- package/dist/bundle/search-list.d.ts +43 -0
- package/dist/bundle/search-list.js +53 -0
- package/dist/bundle/stable-stringify.d.ts +26 -0
- package/dist/bundle/stable-stringify.js +50 -0
- package/dist/cache/contract.d.ts +157 -0
- package/dist/cache/contract.js +0 -0
- package/dist/cache/disk-index.d.ts +82 -0
- package/dist/cache/disk-index.js +220 -0
- package/dist/cache/disk.d.ts +133 -0
- package/dist/cache/disk.js +313 -0
- package/dist/cache/gc.d.ts +78 -0
- package/dist/cache/gc.js +123 -0
- package/dist/cache/get-or-compute.d.ts +36 -0
- package/dist/cache/get-or-compute.js +52 -0
- package/dist/cache/index.d.ts +9 -0
- package/dist/cache/index.js +8 -0
- package/dist/cache/keys.d.ts +76 -0
- package/dist/cache/keys.js +78 -0
- package/dist/cache/memory.d.ts +48 -0
- package/dist/cache/memory.js +110 -0
- package/dist/cache-first.d.ts +127 -0
- package/dist/cache-first.js +227 -0
- package/dist/client/errors.d.ts +33 -0
- package/dist/client/errors.js +49 -0
- package/dist/client/http.d.ts +110 -0
- package/dist/client/http.js +207 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- package/dist/client/issues.d.ts +71 -0
- package/dist/client/issues.js +100 -0
- package/dist/client/search.d.ts +58 -0
- package/dist/client/search.js +81 -0
- package/dist/config/credentials.d.ts +247 -0
- package/dist/config/credentials.js +427 -0
- package/dist/config/doctor.d.ts +123 -0
- package/dist/config/doctor.js +260 -0
- package/dist/config/index.d.ts +6 -0
- package/dist/config/index.js +6 -0
- package/dist/config/keyring.d.ts +96 -0
- package/dist/config/keyring.js +158 -0
- package/dist/config/login.d.ts +97 -0
- package/dist/config/login.js +189 -0
- package/dist/config/settings.d.ts +94 -0
- package/dist/config/settings.js +140 -0
- package/dist/contract.d.ts +173 -0
- package/dist/contract.js +27 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +8 -0
- package/dist/extract/audio-extractor.d.ts +105 -0
- package/dist/extract/audio-extractor.js +156 -0
- package/dist/extract/audio.d.ts +126 -0
- package/dist/extract/audio.js +184 -0
- package/dist/extract/dispatcher.d.ts +132 -0
- package/dist/extract/dispatcher.js +115 -0
- package/dist/extract/download.d.ts +111 -0
- package/dist/extract/download.js +261 -0
- package/dist/extract/duration.d.ts +106 -0
- package/dist/extract/duration.js +148 -0
- package/dist/extract/ffmpeg.d.ts +56 -0
- package/dist/extract/ffmpeg.js +95 -0
- package/dist/extract/gguf.d.ts +137 -0
- package/dist/extract/gguf.js +215 -0
- package/dist/extract/index.d.ts +19 -0
- package/dist/extract/index.js +19 -0
- package/dist/extract/magic.d.ts +80 -0
- package/dist/extract/magic.js +282 -0
- package/dist/extract/ooxml.d.ts +131 -0
- package/dist/extract/ooxml.js +336 -0
- package/dist/extract/pdf.d.ts +147 -0
- package/dist/extract/pdf.js +322 -0
- package/dist/extract/queue.d.ts +167 -0
- package/dist/extract/queue.js +217 -0
- package/dist/extract/subprocess.d.ts +145 -0
- package/dist/extract/subprocess.js +181 -0
- package/dist/extract/tesseract.d.ts +153 -0
- package/dist/extract/tesseract.js +321 -0
- package/dist/extract/video-extractor.d.ts +84 -0
- package/dist/extract/video-extractor.js +89 -0
- package/dist/extract/video.d.ts +198 -0
- package/dist/extract/video.js +418 -0
- package/dist/extract/which.d.ts +63 -0
- package/dist/extract/which.js +72 -0
- package/dist/extract/whisper-extract.d.ts +211 -0
- package/dist/extract/whisper-extract.js +323 -0
- package/dist/extract/whisper.d.ts +50 -0
- package/dist/extract/whisper.js +67 -0
- package/dist/extract/zip.d.ts +50 -0
- package/dist/extract/zip.js +165 -0
- package/dist/extract-issue-attachments.d.ts +82 -0
- package/dist/extract-issue-attachments.js +156 -0
- package/dist/fetch-attachment-text.d.ts +113 -0
- package/dist/fetch-attachment-text.js +155 -0
- package/dist/fetch-issue-bundle.d.ts +81 -0
- package/dist/fetch-issue-bundle.js +93 -0
- package/dist/fetch-issue-search.d.ts +74 -0
- package/dist/fetch-issue-search.js +120 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +67 -0
- package/dist/normalize/collections.d.ts +52 -0
- package/dist/normalize/collections.js +170 -0
- package/dist/normalize/helpers.d.ts +35 -0
- package/dist/normalize/helpers.js +59 -0
- package/dist/normalize/index.d.ts +2 -0
- package/dist/normalize/index.js +2 -0
- package/dist/normalize/issue.d.ts +34 -0
- package/dist/normalize/issue.js +154 -0
- package/dist/surfaces/cli/commands.d.ts +61 -0
- package/dist/surfaces/cli/commands.js +262 -0
- package/dist/surfaces/cli/main.d.ts +32 -0
- package/dist/surfaces/cli/main.js +210 -0
- package/dist/surfaces/cli/prompts.d.ts +66 -0
- package/dist/surfaces/cli/prompts.js +147 -0
- package/dist/surfaces/cli/tty.d.ts +27 -0
- package/dist/surfaces/cli/tty.js +35 -0
- package/dist/surfaces/cli/types.d.ts +39 -0
- package/dist/surfaces/cli/types.js +7 -0
- package/dist/surfaces/mcp/server.d.ts +171 -0
- package/dist/surfaces/mcp/server.js +427 -0
- package/dist/surfaces/tui/app.d.ts +55 -0
- package/dist/surfaces/tui/app.js +180 -0
- package/dist/surfaces/tui/attachment-status.d.ts +79 -0
- package/dist/surfaces/tui/attachment-status.js +113 -0
- package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
- package/dist/surfaces/tui/components/breadcrumb.js +31 -0
- package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
- package/dist/surfaces/tui/components/gradient-text.js +75 -0
- package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
- package/dist/surfaces/tui/components/scroll-view.js +77 -0
- package/dist/surfaces/tui/components/spinner.d.ts +12 -0
- package/dist/surfaces/tui/components/spinner.js +39 -0
- package/dist/surfaces/tui/components/text-input.d.ts +45 -0
- package/dist/surfaces/tui/components/text-input.js +114 -0
- package/dist/surfaces/tui/format-file-size.d.ts +24 -0
- package/dist/surfaces/tui/format-file-size.js +43 -0
- package/dist/surfaces/tui/glyphs.d.ts +47 -0
- package/dist/surfaces/tui/glyphs.js +84 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
- package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
- package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
- package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
- package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
- package/dist/surfaces/tui/index.d.ts +50 -0
- package/dist/surfaces/tui/index.js +158 -0
- package/dist/surfaces/tui/instance.d.ts +37 -0
- package/dist/surfaces/tui/instance.js +36 -0
- package/dist/surfaces/tui/job-registry.d.ts +113 -0
- package/dist/surfaces/tui/job-registry.js +123 -0
- package/dist/surfaces/tui/job-status.d.ts +45 -0
- package/dist/surfaces/tui/job-status.js +81 -0
- package/dist/surfaces/tui/navigation.d.ts +74 -0
- package/dist/surfaces/tui/navigation.js +87 -0
- package/dist/surfaces/tui/palettes.d.ts +38 -0
- package/dist/surfaces/tui/palettes.js +244 -0
- package/dist/surfaces/tui/screen.d.ts +30 -0
- package/dist/surfaces/tui/screen.js +51 -0
- package/dist/surfaces/tui/screens/about.d.ts +2 -0
- package/dist/surfaces/tui/screens/about.js +35 -0
- package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
- package/dist/surfaces/tui/screens/appearance.js +74 -0
- package/dist/surfaces/tui/screens/config.d.ts +2 -0
- package/dist/surfaces/tui/screens/config.js +82 -0
- package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
- package/dist/surfaces/tui/screens/doctor.js +109 -0
- package/dist/surfaces/tui/screens/export.d.ts +2 -0
- package/dist/surfaces/tui/screens/export.js +168 -0
- package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
- package/dist/surfaces/tui/screens/home-selection.js +80 -0
- package/dist/surfaces/tui/screens/home.d.ts +2 -0
- package/dist/surfaces/tui/screens/home.js +200 -0
- package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
- package/dist/surfaces/tui/screens/issue-detail.js +182 -0
- package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
- package/dist/surfaces/tui/screens/jobs.js +89 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
- package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
- package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
- package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
- package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
- package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
- package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
- package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
- package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
- package/dist/surfaces/tui/screens/welcome.js +95 -0
- package/dist/surfaces/tui/status-color.d.ts +21 -0
- package/dist/surfaces/tui/status-color.js +23 -0
- package/dist/surfaces/tui/symbols.d.ts +231 -0
- package/dist/surfaces/tui/symbols.js +14 -0
- package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
- package/dist/surfaces/tui/terminal-colors.js +39 -0
- package/dist/surfaces/tui/theme.d.ts +156 -0
- package/dist/surfaces/tui/theme.js +86 -0
- package/dist/surfaces/tui/truncate.d.ts +31 -0
- package/dist/surfaces/tui/truncate.js +81 -0
- package/package.json +93 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Núcleo da fila de jobs de extração (#67 / M4-10.1, ADR-005).
|
|
3
|
+
*
|
|
4
|
+
* A extração de mídia é longa (minutos) e há vários anexos por issue; esta fila
|
|
5
|
+
* roda os jobs com **concorrência limitada** (`os.cpus().length − 1`, piso de 1),
|
|
6
|
+
* expõe as **transições de estado de forma observável** e **isola falhas** — um
|
|
7
|
+
* job que rejeita vira `failed` sem derrubar os demais.
|
|
8
|
+
*
|
|
9
|
+
* ## Observabilidade — contrato do core (ADR-005)
|
|
10
|
+
*
|
|
11
|
+
* A fila é consumida como `AsyncIterable<QueueEvent<T>>`, o mesmo padrão
|
|
12
|
+
* `AsyncIterable<ProgressEvent | Result>` que as superfícies já usam: cada
|
|
13
|
+
* transição de um job é um item da sequência, e o iterável termina quando a fila
|
|
14
|
+
* drena. O status REUSA o vocabulário canônico {@link ExtractionStatus}
|
|
15
|
+
* (`pending → processing → done | failed | cancelled`) — sem status paralelo.
|
|
16
|
+
*
|
|
17
|
+
* ## Cancelamento (#69)
|
|
18
|
+
*
|
|
19
|
+
* A fila aceita um `AbortSignal` opcional ({@link RunQueueOptions.signal}) e o
|
|
20
|
+
* propaga a cada job via {@link JobContext.signal}. Ao abortar: os jobs em
|
|
21
|
+
* execução recebem o signal (o extrator o repassa ao `runWithWatchdog`, que MATA
|
|
22
|
+
* o subprocesso) e são observados como `cancelled`; os jobs `pending` que ainda
|
|
23
|
+
* não iniciaram são marcados `cancelled` sem iniciar. Cada transição emite UM
|
|
24
|
+
* evento — as superfícies consomem só o `AsyncIterable`, sem API de cancelamento
|
|
25
|
+
* acoplada.
|
|
26
|
+
*
|
|
27
|
+
* ## Fronteira e escopo
|
|
28
|
+
*
|
|
29
|
+
* Módulo de core (pipeline de extração): não importa de `src/surfaces/**` e não
|
|
30
|
+
* usa `console.*` — falhas são reportadas via {@link Logger} injetado. O
|
|
31
|
+
* {@link JobContext} passado a cada job é o ponto de extensão do timeout/kill de
|
|
32
|
+
* subprocesso (#68, `timeoutMs`) e do cancelamento (#69, `signal`), sem quebrar a
|
|
33
|
+
* assinatura de {@link QueueJob}.
|
|
34
|
+
*/
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
/**
|
|
37
|
+
* Limite de concorrência padrão da fila: `os.cpus().length − 1`, com piso de 1
|
|
38
|
+
* para máquinas de 1 núcleo (ADR-005 — deixa um núcleo livre para a UI/event
|
|
39
|
+
* loop enquanto o binário externo trabalha).
|
|
40
|
+
*
|
|
41
|
+
* @returns O número máximo de jobs simultâneos recomendado para esta máquina.
|
|
42
|
+
*/
|
|
43
|
+
export function defaultConcurrency() {
|
|
44
|
+
return Math.max(1, os.cpus().length - 1);
|
|
45
|
+
}
|
|
46
|
+
/** Converte um erro arbitrário em uma mensagem legível para o `reason`. */
|
|
47
|
+
function describeError(error) {
|
|
48
|
+
return error instanceof Error ? error.message : String(error);
|
|
49
|
+
}
|
|
50
|
+
function createChannel() {
|
|
51
|
+
const buffer = [];
|
|
52
|
+
let closed = false;
|
|
53
|
+
let wake;
|
|
54
|
+
const notify = () => {
|
|
55
|
+
const resume = wake;
|
|
56
|
+
wake = undefined;
|
|
57
|
+
resume?.();
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
emit(value) {
|
|
61
|
+
buffer.push(value);
|
|
62
|
+
notify();
|
|
63
|
+
},
|
|
64
|
+
close() {
|
|
65
|
+
closed = true;
|
|
66
|
+
notify();
|
|
67
|
+
},
|
|
68
|
+
async *stream() {
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (buffer.length > 0) {
|
|
71
|
+
yield buffer.shift();
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (closed) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
await new Promise((resolve) => {
|
|
78
|
+
wake = resolve;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Roda uma coleção de jobs com concorrência limitada, expondo cada transição de
|
|
86
|
+
* estado como um `AsyncIterable<QueueEvent<T>>` (contrato do core, ADR-005).
|
|
87
|
+
*
|
|
88
|
+
* Garantias:
|
|
89
|
+
* - **Concorrência**: nunca há mais que `concurrency` jobs em `processing` ao
|
|
90
|
+
* mesmo tempo; os excedentes ficam em `pending` até um slot liberar (FIFO —
|
|
91
|
+
* a ordem de enfileiramento é respeitada).
|
|
92
|
+
* - **Observabilidade**: todo job emite `pending` (no enfileiramento), depois
|
|
93
|
+
* `processing` (ao iniciar) e por fim `done` (com `result`) ou `failed` (com
|
|
94
|
+
* `reason`/`error`).
|
|
95
|
+
* - **Isolamento de falha**: um job que rejeita vira `failed` e NÃO interrompe
|
|
96
|
+
* os demais — a fila continua até drenar. `runQueue` nunca rejeita por causa
|
|
97
|
+
* de um job.
|
|
98
|
+
*
|
|
99
|
+
* @typeParam T - Tipo do resultado dos jobs em caso de sucesso.
|
|
100
|
+
* @param jobs - Jobs a executar, na ordem de prioridade (FIFO).
|
|
101
|
+
* @param options - Concorrência e logger — ver {@link RunQueueOptions}.
|
|
102
|
+
* @returns Sequência assíncrona de transições, terminada quando a fila drena.
|
|
103
|
+
* @example
|
|
104
|
+
* for await (const event of runQueue(jobs, { concurrency: 2 })) {
|
|
105
|
+
* if (event.status === 'failed') logger.warn(event.reason ?? 'falhou');
|
|
106
|
+
* }
|
|
107
|
+
*/
|
|
108
|
+
export function runQueue(jobs, options = {}) {
|
|
109
|
+
const concurrency = Math.max(1, options.concurrency ?? defaultConcurrency());
|
|
110
|
+
const logger = options.logger;
|
|
111
|
+
const jobTimeoutMs = options.jobTimeoutMs;
|
|
112
|
+
const signal = options.signal;
|
|
113
|
+
const channel = createChannel();
|
|
114
|
+
const pending = [...jobs];
|
|
115
|
+
// Todos os jobs são observados como `pending` já no enfileiramento (ordem FIFO).
|
|
116
|
+
for (const job of pending) {
|
|
117
|
+
channel.emit({ id: job.id, status: 'pending' });
|
|
118
|
+
}
|
|
119
|
+
let active = 0;
|
|
120
|
+
let next = 0;
|
|
121
|
+
// Reason: fecha o canal E remove o listener de abort — evita vazar a inscrição
|
|
122
|
+
// num `AbortSignal` de vida longa quando a fila drena sem cancelamento.
|
|
123
|
+
const closeChannel = () => {
|
|
124
|
+
signal?.removeEventListener('abort', onAbort);
|
|
125
|
+
channel.close();
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Marca como `cancelled` todo job `pending` que ainda NÃO iniciou (índice a
|
|
129
|
+
* partir de `next`), impedindo que o `pump` os inicie, e fecha o canal se não há
|
|
130
|
+
* mais nada em execução. Os jobs em execução NÃO são tocados aqui: eles recebem
|
|
131
|
+
* o `signal` (matam o subprocesso) e viram `cancelled` no `catch` de {@link settle}.
|
|
132
|
+
*/
|
|
133
|
+
const cancelRemaining = () => {
|
|
134
|
+
while (next < pending.length) {
|
|
135
|
+
const job = pending[next];
|
|
136
|
+
next += 1;
|
|
137
|
+
if (job === undefined) {
|
|
138
|
+
continue; // Inalcançável dado o bound; satisfaz noUncheckedIndexedAccess.
|
|
139
|
+
}
|
|
140
|
+
channel.emit({ id: job.id, status: 'cancelled' });
|
|
141
|
+
}
|
|
142
|
+
if (active === 0) {
|
|
143
|
+
closeChannel();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
// Reason: função (hoisted) para que `closeChannel` possa desinscrevê-la.
|
|
147
|
+
function onAbort() {
|
|
148
|
+
cancelRemaining();
|
|
149
|
+
}
|
|
150
|
+
const settle = async (job) => {
|
|
151
|
+
try {
|
|
152
|
+
// `processing` dentro do try: se a emissão falhar, o finally ainda roda e o
|
|
153
|
+
// slot é liberado (sem leak). Cobre também `job.run` que lança SÍNCRONO.
|
|
154
|
+
channel.emit({ id: job.id, status: 'processing' });
|
|
155
|
+
// Reason: só inclui `timeoutMs`/`signal` quando a fila os tem — nunca injeta
|
|
156
|
+
// `undefined` (exactOptionalPropertyTypes) no contexto do job.
|
|
157
|
+
const context = {
|
|
158
|
+
jobId: job.id,
|
|
159
|
+
...(jobTimeoutMs !== undefined ? { timeoutMs: jobTimeoutMs } : {}),
|
|
160
|
+
...(signal !== undefined ? { signal } : {}),
|
|
161
|
+
};
|
|
162
|
+
const result = await job.run(context);
|
|
163
|
+
channel.emit({
|
|
164
|
+
id: job.id,
|
|
165
|
+
status: 'done',
|
|
166
|
+
...(result !== undefined ? { result } : {}),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
// Cancelamento vence a falha: uma vez abortada, a rejeição do job (subprocesso
|
|
171
|
+
// morto) é uma transição `cancelled`, não `failed` — um evento por transição.
|
|
172
|
+
if (signal?.aborted === true) {
|
|
173
|
+
channel.emit({ id: job.id, status: 'cancelled' });
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
// Isolamento de falha: reporta, mas a fila segue com os demais jobs.
|
|
177
|
+
const reason = describeError(error);
|
|
178
|
+
logger?.warn(`queue: job "${job.id}" falhou — ${reason}`);
|
|
179
|
+
channel.emit({ id: job.id, status: 'failed', reason, ...(error !== undefined ? { error } : {}) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
active -= 1;
|
|
184
|
+
// Fora da pilha do `pump`: um job que lança SÍNCRONO rodaria o finally de
|
|
185
|
+
// forma reentrante, gerando recursão O(N) (risco de stack overflow) e
|
|
186
|
+
// `close()` redundante. O microtask serializa as continuações.
|
|
187
|
+
queueMicrotask(pump);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
/** Preenche slots livres respeitando o limite; fecha o canal quando drena. */
|
|
191
|
+
function pump() {
|
|
192
|
+
// Após um abort, não inicia novos jobs: drena os pendentes como `cancelled`.
|
|
193
|
+
if (signal?.aborted === true) {
|
|
194
|
+
cancelRemaining();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
while (active < concurrency && next < pending.length) {
|
|
198
|
+
const job = pending[next];
|
|
199
|
+
next += 1;
|
|
200
|
+
if (job === undefined) {
|
|
201
|
+
break; // Inalcançável dado o bound; satisfaz noUncheckedIndexedAccess.
|
|
202
|
+
}
|
|
203
|
+
active += 1;
|
|
204
|
+
void settle(job);
|
|
205
|
+
}
|
|
206
|
+
if (active === 0 && next >= pending.length) {
|
|
207
|
+
closeChannel();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// O cancelamento é observado por evento (contrato AsyncIterable) — sem API
|
|
211
|
+
// especial acoplada às superfícies (TUI/CLI consomem só o iterável).
|
|
212
|
+
if (signal !== undefined && !signal.aborted) {
|
|
213
|
+
signal.addEventListener('abort', onAbort);
|
|
214
|
+
}
|
|
215
|
+
pump();
|
|
216
|
+
return channel.stream();
|
|
217
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilitário COMPARTILHADO de subprocesso para os extratores de mídia (M4-10.2,
|
|
3
|
+
* #68, ADR-002).
|
|
4
|
+
*
|
|
5
|
+
* Consolida num único lugar as duas garantias de segurança/robustez que antes
|
|
6
|
+
* eram duplicadas (≥3 cópias) em {@link convertAudioToWav}, {@link WhisperExtractor}
|
|
7
|
+
* e {@link TesseractExtractor} — a review do #60 apontou essa duplicação como
|
|
8
|
+
* dívida:
|
|
9
|
+
*
|
|
10
|
+
* 1. **ENV SANITIZADO** ({@link sanitizedEnv}): o subprocesso recebe um env MÍNIMO
|
|
11
|
+
* e por ALLOWLIST — só `PATH` (com fallback), o que o chamador permitir
|
|
12
|
+
* explicitamente (ex.: `TESSDATA_PREFIX` do tesseract) e, no Windows, um punhado
|
|
13
|
+
* de variáveis NÃO-SECRETAS essenciais (`SystemRoot`/`windir`) para robustez
|
|
14
|
+
* (sugestão da review do #60). Segredos do processo pai (ex.: `REDMINE_API_KEY`)
|
|
15
|
+
* NUNCA são propagados — o env é montado por inclusão, não por herança.
|
|
16
|
+
*
|
|
17
|
+
* 2. **WATCHDOG DE TIMEOUT + KILL** ({@link runWithWatchdog}): roda o binário SEM
|
|
18
|
+
* shell (`execFile`) e, se estourar o timeout, envia `SIGTERM` e — após a graça
|
|
19
|
+
* — `SIGKILL`, rejeitando com um erro de timeout. Um binário travado NUNCA
|
|
20
|
+
* pendura a fila de jobs nem deixa PROCESSO ZUMBI (o `kill` encerra o child).
|
|
21
|
+
*
|
|
22
|
+
* TESTABILIDADE: `platform`/`source` do env e o `execFile` do watchdog são
|
|
23
|
+
* INJETÁVEIS — os três SOs e o escalonamento `SIGTERM`→`SIGKILL` são testáveis num
|
|
24
|
+
* único host, com fake timers e sem subprocesso real. O erro de timeout é
|
|
25
|
+
* INJETÁVEL (`makeTimeoutError`) para que cada extrator preserve seu tipo de erro
|
|
26
|
+
* canônico (ex.: `FfmpegTimeoutError`) sem duplicar o watchdog.
|
|
27
|
+
*
|
|
28
|
+
* Fronteira: módulo de core (pipeline de extração) — sem `console.*` (ADR-005) e
|
|
29
|
+
* sem importar de `src/surfaces/**`.
|
|
30
|
+
*/
|
|
31
|
+
/** Opções de {@link sanitizedEnv} — todas injetáveis para testes multi-SO. */
|
|
32
|
+
export interface SanitizedEnvOptions {
|
|
33
|
+
/** SO alvo; default `process.platform`. Decide as variáveis essenciais do Windows. */
|
|
34
|
+
readonly platform?: NodeJS.Platform;
|
|
35
|
+
/** Env de origem; default `process.env`. É LIDO por allowlist, nunca herdado inteiro. */
|
|
36
|
+
readonly source?: NodeJS.ProcessEnv;
|
|
37
|
+
/**
|
|
38
|
+
* Chaves adicionais permitidas ALÉM de `PATH` (ex.: `TESSDATA_PREFIX`). Cada
|
|
39
|
+
* chave só entra no resultado se estiver definida na origem (respeita
|
|
40
|
+
* `exactOptionalPropertyTypes`).
|
|
41
|
+
*/
|
|
42
|
+
readonly allow?: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Monta o env MÍNIMO e EXPLÍCITO de um subprocesso por ALLOWLIST (ADR-002). O
|
|
46
|
+
* resultado contém `PATH` (com fallback) mais, apenas se definidas na origem, as
|
|
47
|
+
* chaves de {@link SanitizedEnvOptions.allow} e — no Windows — as variáveis
|
|
48
|
+
* essenciais não-secretas ({@link WINDOWS_ESSENTIAL_ENV}). Nenhum segredo do
|
|
49
|
+
* processo pai (ex.: `REDMINE_API_KEY`) é propagado.
|
|
50
|
+
*
|
|
51
|
+
* @param options - Plataforma, origem e allowlist — ver {@link SanitizedEnvOptions}.
|
|
52
|
+
* @returns Env sanitizado, seguro para passar a `execFile`.
|
|
53
|
+
* @example
|
|
54
|
+
* sanitizedEnv(); // { PATH } (+ essenciais no Windows)
|
|
55
|
+
* sanitizedEnv({ allow: ['TESSDATA_PREFIX'] }); // + TESSDATA_PREFIX, se definida
|
|
56
|
+
*/
|
|
57
|
+
export declare function sanitizedEnv(options?: SanitizedEnvOptions): NodeJS.ProcessEnv;
|
|
58
|
+
/** Child mínimo que o watchdog precisa: só a capacidade de enviar um sinal de kill. */
|
|
59
|
+
export interface WatchdogChild {
|
|
60
|
+
/** Envia um sinal ao processo; retorna `true` se o sinal foi entregue. */
|
|
61
|
+
kill(signal: NodeJS.Signals): boolean;
|
|
62
|
+
}
|
|
63
|
+
/** Callback do `execFile` na forma `(error, stdout, stderr)` com `encoding: 'utf8'`. */
|
|
64
|
+
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Forma estrutural do `execFile` que o watchdog usa (sem shell, `encoding: 'utf8'`).
|
|
67
|
+
* Injetável nos testes por um fake determinístico, sem tocar `child_process` real.
|
|
68
|
+
*/
|
|
69
|
+
export type ExecFileLike = (file: string, args: readonly string[], options: {
|
|
70
|
+
readonly env: NodeJS.ProcessEnv;
|
|
71
|
+
readonly encoding: 'utf8';
|
|
72
|
+
readonly maxBuffer: number;
|
|
73
|
+
readonly windowsHide: boolean;
|
|
74
|
+
}, callback: ExecFileCallback) => WatchdogChild;
|
|
75
|
+
/** Invocação concreta de um subprocesso vigiado pelo {@link runWithWatchdog}. */
|
|
76
|
+
export interface WatchdogInvocation {
|
|
77
|
+
/** Caminho absoluto do binário já resolvido. */
|
|
78
|
+
readonly bin: string;
|
|
79
|
+
/** Argumentos (sem shell) — lista explícita, nunca interpolada. */
|
|
80
|
+
readonly args: readonly string[];
|
|
81
|
+
/** Env SANITIZADO do subprocesso (ver {@link sanitizedEnv}). */
|
|
82
|
+
readonly env: NodeJS.ProcessEnv;
|
|
83
|
+
/** Timeout antes do `SIGTERM` (ms). */
|
|
84
|
+
readonly timeoutMs: number;
|
|
85
|
+
/** Graça entre `SIGTERM` e `SIGKILL` (ms). */
|
|
86
|
+
readonly killGraceMs: number;
|
|
87
|
+
/** Teto do stdout/stderr capturado (bytes). */
|
|
88
|
+
readonly maxBuffer: number;
|
|
89
|
+
/**
|
|
90
|
+
* Sinal de CANCELAMENTO (#69, ADR-005). Quando dispara, o subprocesso é MORTO
|
|
91
|
+
* com o mesmo escalonamento do timeout (`SIGTERM`→ graça →`SIGKILL`) e a
|
|
92
|
+
* Promise rejeita com {@link SubprocessAbortedError} (ou o erro de
|
|
93
|
+
* {@link WatchdogDeps.makeAbortError}). Se já estiver `aborted` na chamada, o
|
|
94
|
+
* processo é morto de imediato. Campo OPCIONAL e ADITIVO — extratores que não
|
|
95
|
+
* o passam mantêm exatamente o comportamento anterior (#68).
|
|
96
|
+
*/
|
|
97
|
+
readonly signal?: AbortSignal;
|
|
98
|
+
}
|
|
99
|
+
/** Dependências injetáveis de {@link runWithWatchdog} — defaults de produção. */
|
|
100
|
+
export interface WatchdogDeps {
|
|
101
|
+
/** Spawner sem shell; default: `execFile` de `node:child_process`. */
|
|
102
|
+
readonly execFile?: ExecFileLike;
|
|
103
|
+
/**
|
|
104
|
+
* Fábrica do erro rejeitado no timeout. Default: {@link SubprocessTimeoutError}.
|
|
105
|
+
* Cada extrator injeta seu erro canônico (ex.: `FfmpegTimeoutError`) para manter
|
|
106
|
+
* a classificação de falha existente sem reimplementar o watchdog.
|
|
107
|
+
*/
|
|
108
|
+
readonly makeTimeoutError?: (timeoutMs: number) => Error;
|
|
109
|
+
/**
|
|
110
|
+
* Fábrica do erro rejeitado no cancelamento via `AbortSignal` (#69). Default:
|
|
111
|
+
* {@link SubprocessAbortedError}. Injetável para que um extrator preserve seu
|
|
112
|
+
* tipo de erro canônico ao ser cancelado, sem reimplementar o watchdog.
|
|
113
|
+
*/
|
|
114
|
+
readonly makeAbortError?: () => Error;
|
|
115
|
+
}
|
|
116
|
+
/** Erro default do watchdog: o subprocesso estourou o timeout e foi encerrado. */
|
|
117
|
+
export declare class SubprocessTimeoutError extends Error {
|
|
118
|
+
readonly timeoutMs: number;
|
|
119
|
+
constructor(timeoutMs: number);
|
|
120
|
+
}
|
|
121
|
+
/** Erro default do watchdog quando o subprocesso é MORTO por cancelamento (#69). */
|
|
122
|
+
export declare class SubprocessAbortedError extends Error {
|
|
123
|
+
constructor();
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Roda um binário SEM shell com env sanitizado e um WATCHDOG de timeout: no
|
|
127
|
+
* estouro, envia `SIGTERM` e, após a graça, `SIGKILL` — garantindo que um processo
|
|
128
|
+
* travado seja encerrado (sem zumbi) e não pendure a fila de jobs (ADR-002).
|
|
129
|
+
*
|
|
130
|
+
* Resolve com o `stdout` no exit 0; rejeita com o erro bruto do `execFile` em exit
|
|
131
|
+
* != 0; rejeita com o erro de {@link WatchdogDeps.makeTimeoutError} (default
|
|
132
|
+
* {@link SubprocessTimeoutError}) no timeout. Todas as transições passam por um
|
|
133
|
+
* `settle` idempotente (uma única resolução; timers sempre limpos — sem leak).
|
|
134
|
+
*
|
|
135
|
+
* @param invocation - Binário, args, env e limites — ver {@link WatchdogInvocation}.
|
|
136
|
+
* @param deps - `execFile`/`makeTimeoutError` injetáveis — ver {@link WatchdogDeps}.
|
|
137
|
+
* @returns O `stdout` do processo em caso de sucesso.
|
|
138
|
+
* @example
|
|
139
|
+
* const out = await runWithWatchdog({
|
|
140
|
+
* bin: '/opt/bin/tesseract', args: ['img.png', 'stdout'],
|
|
141
|
+
* env: sanitizedEnv(), timeoutMs: 30_000, killGraceMs: 2_000, maxBuffer: 16 << 20,
|
|
142
|
+
* });
|
|
143
|
+
*/
|
|
144
|
+
export declare function runWithWatchdog(invocation: WatchdogInvocation, deps?: WatchdogDeps): Promise<string>;
|
|
145
|
+
export {};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilitário COMPARTILHADO de subprocesso para os extratores de mídia (M4-10.2,
|
|
3
|
+
* #68, ADR-002).
|
|
4
|
+
*
|
|
5
|
+
* Consolida num único lugar as duas garantias de segurança/robustez que antes
|
|
6
|
+
* eram duplicadas (≥3 cópias) em {@link convertAudioToWav}, {@link WhisperExtractor}
|
|
7
|
+
* e {@link TesseractExtractor} — a review do #60 apontou essa duplicação como
|
|
8
|
+
* dívida:
|
|
9
|
+
*
|
|
10
|
+
* 1. **ENV SANITIZADO** ({@link sanitizedEnv}): o subprocesso recebe um env MÍNIMO
|
|
11
|
+
* e por ALLOWLIST — só `PATH` (com fallback), o que o chamador permitir
|
|
12
|
+
* explicitamente (ex.: `TESSDATA_PREFIX` do tesseract) e, no Windows, um punhado
|
|
13
|
+
* de variáveis NÃO-SECRETAS essenciais (`SystemRoot`/`windir`) para robustez
|
|
14
|
+
* (sugestão da review do #60). Segredos do processo pai (ex.: `REDMINE_API_KEY`)
|
|
15
|
+
* NUNCA são propagados — o env é montado por inclusão, não por herança.
|
|
16
|
+
*
|
|
17
|
+
* 2. **WATCHDOG DE TIMEOUT + KILL** ({@link runWithWatchdog}): roda o binário SEM
|
|
18
|
+
* shell (`execFile`) e, se estourar o timeout, envia `SIGTERM` e — após a graça
|
|
19
|
+
* — `SIGKILL`, rejeitando com um erro de timeout. Um binário travado NUNCA
|
|
20
|
+
* pendura a fila de jobs nem deixa PROCESSO ZUMBI (o `kill` encerra o child).
|
|
21
|
+
*
|
|
22
|
+
* TESTABILIDADE: `platform`/`source` do env e o `execFile` do watchdog são
|
|
23
|
+
* INJETÁVEIS — os três SOs e o escalonamento `SIGTERM`→`SIGKILL` são testáveis num
|
|
24
|
+
* único host, com fake timers e sem subprocesso real. O erro de timeout é
|
|
25
|
+
* INJETÁVEL (`makeTimeoutError`) para que cada extrator preserve seu tipo de erro
|
|
26
|
+
* canônico (ex.: `FfmpegTimeoutError`) sem duplicar o watchdog.
|
|
27
|
+
*
|
|
28
|
+
* Fronteira: módulo de core (pipeline de extração) — sem `console.*` (ADR-005) e
|
|
29
|
+
* sem importar de `src/surfaces/**`.
|
|
30
|
+
*/
|
|
31
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
32
|
+
/**
|
|
33
|
+
* Variáveis NÃO-SECRETAS essenciais no Windows, preservadas por {@link sanitizedEnv}
|
|
34
|
+
* para robustez (sem elas, alguns binários nativos falham ao resolver caminhos do
|
|
35
|
+
* sistema). Nenhuma carrega segredo — são metadados de instalação do SO.
|
|
36
|
+
*/
|
|
37
|
+
const WINDOWS_ESSENTIAL_ENV = ['SystemRoot', 'windir', 'SystemDrive', 'PATHEXT'];
|
|
38
|
+
/** `PATH` mínimo usado quando a origem não o define (ambientes muito enxutos). */
|
|
39
|
+
const FALLBACK_PATH = '/usr/bin:/bin';
|
|
40
|
+
/**
|
|
41
|
+
* Monta o env MÍNIMO e EXPLÍCITO de um subprocesso por ALLOWLIST (ADR-002). O
|
|
42
|
+
* resultado contém `PATH` (com fallback) mais, apenas se definidas na origem, as
|
|
43
|
+
* chaves de {@link SanitizedEnvOptions.allow} e — no Windows — as variáveis
|
|
44
|
+
* essenciais não-secretas ({@link WINDOWS_ESSENTIAL_ENV}). Nenhum segredo do
|
|
45
|
+
* processo pai (ex.: `REDMINE_API_KEY`) é propagado.
|
|
46
|
+
*
|
|
47
|
+
* @param options - Plataforma, origem e allowlist — ver {@link SanitizedEnvOptions}.
|
|
48
|
+
* @returns Env sanitizado, seguro para passar a `execFile`.
|
|
49
|
+
* @example
|
|
50
|
+
* sanitizedEnv(); // { PATH } (+ essenciais no Windows)
|
|
51
|
+
* sanitizedEnv({ allow: ['TESSDATA_PREFIX'] }); // + TESSDATA_PREFIX, se definida
|
|
52
|
+
*/
|
|
53
|
+
export function sanitizedEnv(options = {}) {
|
|
54
|
+
const platform = options.platform ?? process.platform;
|
|
55
|
+
const source = options.source ?? process.env;
|
|
56
|
+
const env = { PATH: source.PATH ?? FALLBACK_PATH };
|
|
57
|
+
const allowed = platform === 'win32'
|
|
58
|
+
? [...WINDOWS_ESSENTIAL_ENV, ...(options.allow ?? [])]
|
|
59
|
+
: (options.allow ?? []);
|
|
60
|
+
for (const key of allowed) {
|
|
61
|
+
const value = source[key];
|
|
62
|
+
// Reason: só inclui a chave quando há valor — nunca injeta `undefined`
|
|
63
|
+
// (exactOptionalPropertyTypes) e mantém o env estritamente mínimo.
|
|
64
|
+
if (value !== undefined) {
|
|
65
|
+
env[key] = value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return env;
|
|
69
|
+
}
|
|
70
|
+
/** Erro default do watchdog: o subprocesso estourou o timeout e foi encerrado. */
|
|
71
|
+
export class SubprocessTimeoutError extends Error {
|
|
72
|
+
timeoutMs;
|
|
73
|
+
constructor(timeoutMs) {
|
|
74
|
+
super(`subprocesso excedeu o timeout de ${timeoutMs}ms e foi encerrado`);
|
|
75
|
+
this.timeoutMs = timeoutMs;
|
|
76
|
+
this.name = 'SubprocessTimeoutError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Erro default do watchdog quando o subprocesso é MORTO por cancelamento (#69). */
|
|
80
|
+
export class SubprocessAbortedError extends Error {
|
|
81
|
+
constructor() {
|
|
82
|
+
super('subprocesso cancelado via AbortSignal e foi encerrado');
|
|
83
|
+
this.name = 'SubprocessAbortedError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Roda um binário SEM shell com env sanitizado e um WATCHDOG de timeout: no
|
|
88
|
+
* estouro, envia `SIGTERM` e, após a graça, `SIGKILL` — garantindo que um processo
|
|
89
|
+
* travado seja encerrado (sem zumbi) e não pendure a fila de jobs (ADR-002).
|
|
90
|
+
*
|
|
91
|
+
* Resolve com o `stdout` no exit 0; rejeita com o erro bruto do `execFile` em exit
|
|
92
|
+
* != 0; rejeita com o erro de {@link WatchdogDeps.makeTimeoutError} (default
|
|
93
|
+
* {@link SubprocessTimeoutError}) no timeout. Todas as transições passam por um
|
|
94
|
+
* `settle` idempotente (uma única resolução; timers sempre limpos — sem leak).
|
|
95
|
+
*
|
|
96
|
+
* @param invocation - Binário, args, env e limites — ver {@link WatchdogInvocation}.
|
|
97
|
+
* @param deps - `execFile`/`makeTimeoutError` injetáveis — ver {@link WatchdogDeps}.
|
|
98
|
+
* @returns O `stdout` do processo em caso de sucesso.
|
|
99
|
+
* @example
|
|
100
|
+
* const out = await runWithWatchdog({
|
|
101
|
+
* bin: '/opt/bin/tesseract', args: ['img.png', 'stdout'],
|
|
102
|
+
* env: sanitizedEnv(), timeoutMs: 30_000, killGraceMs: 2_000, maxBuffer: 16 << 20,
|
|
103
|
+
* });
|
|
104
|
+
*/
|
|
105
|
+
export function runWithWatchdog(invocation, deps = {}) {
|
|
106
|
+
const { bin, args, env, timeoutMs, killGraceMs, maxBuffer, signal } = invocation;
|
|
107
|
+
// Reason: as sobrecargas de `execFile` não colapsam na forma estrutural mínima
|
|
108
|
+
// que usamos (encoding fixo 'utf8'); o cast documentado é seguro — a chamada
|
|
109
|
+
// abaixo respeita exatamente a assinatura real.
|
|
110
|
+
const exec = deps.execFile ?? nodeExecFile;
|
|
111
|
+
const makeTimeoutError = deps.makeTimeoutError ?? ((ms) => new SubprocessTimeoutError(ms));
|
|
112
|
+
const makeAbortError = deps.makeAbortError ?? (() => new SubprocessAbortedError());
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
let settled = false;
|
|
115
|
+
let timedOut = false;
|
|
116
|
+
let aborted = false;
|
|
117
|
+
const timers = [];
|
|
118
|
+
const cleanup = () => {
|
|
119
|
+
for (const timer of timers)
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
signal?.removeEventListener('abort', onAbort);
|
|
122
|
+
};
|
|
123
|
+
const settle = (fn) => {
|
|
124
|
+
if (settled)
|
|
125
|
+
return;
|
|
126
|
+
settled = true;
|
|
127
|
+
cleanup();
|
|
128
|
+
fn();
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Mata o child com o escalonamento `SIGTERM`→ graça →`SIGKILL` e, se o
|
|
132
|
+
* processo não responder na graça, resolve a Promise via `onKilled`. Um
|
|
133
|
+
* processo que responde ao SIGTERM (callback do execFile) faz `settle`
|
|
134
|
+
* primeiro, e o `cleanup` limpa o timer do SIGKILL (anti-pid-reciclado).
|
|
135
|
+
*/
|
|
136
|
+
const killEscalating = (onKilled) => {
|
|
137
|
+
child.kill('SIGTERM');
|
|
138
|
+
// Reason: após a graça, força SIGKILL e desiste — um processo que ignora
|
|
139
|
+
// SIGTERM não pode segurar a fila de jobs indefinidamente (ADR-002).
|
|
140
|
+
timers.push(setTimeout(() => {
|
|
141
|
+
child.kill('SIGKILL');
|
|
142
|
+
onKilled();
|
|
143
|
+
}, killGraceMs));
|
|
144
|
+
};
|
|
145
|
+
// Reason: declaração de função (hoisted) para que `cleanup` possa referenciá-la
|
|
146
|
+
// e para que o listener seja registrado só após o child existir.
|
|
147
|
+
function onAbort() {
|
|
148
|
+
aborted = true;
|
|
149
|
+
killEscalating(() => settle(() => reject(makeAbortError())));
|
|
150
|
+
}
|
|
151
|
+
const child = exec(bin, [...args], { env, encoding: 'utf8', maxBuffer, windowsHide: true }, (error, stdout) => {
|
|
152
|
+
// Ordem: um cancelamento/timeout em curso vence o resultado tardio do
|
|
153
|
+
// processo (que respondeu ao SIGTERM) — nunca vira sucesso/erro comum.
|
|
154
|
+
if (aborted) {
|
|
155
|
+
settle(() => reject(makeAbortError()));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (timedOut) {
|
|
159
|
+
settle(() => reject(makeTimeoutError(timeoutMs)));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (error !== null) {
|
|
163
|
+
settle(() => reject(error));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
settle(() => resolve(stdout));
|
|
167
|
+
});
|
|
168
|
+
timers.push(setTimeout(() => {
|
|
169
|
+
timedOut = true;
|
|
170
|
+
killEscalating(() => settle(() => reject(makeTimeoutError(timeoutMs))));
|
|
171
|
+
}, timeoutMs));
|
|
172
|
+
if (signal !== undefined) {
|
|
173
|
+
if (signal.aborted) {
|
|
174
|
+
onAbort();
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
signal.addEventListener('abort', onAbort);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|