@fireemu/win32-x64 0.7.0 → 0.8.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.
@@ -0,0 +1,29 @@
1
+ import { boundLogMessage } from './log-context.mjs';
2
+
3
+ // User callbacks may reject with anything, including throwing getters or a
4
+ // revoked Proxy. Error formatting must not reject a second time and strand the
5
+ // daemon's invocation waiter. Do not invoke arbitrary toString/toJSON hooks.
6
+ export function invocationFailure(error) {
7
+ let message = 'Function invocation failed';
8
+ if (typeof error === 'string') {
9
+ message = error;
10
+ } else {
11
+ try {
12
+ const candidate = error?.message;
13
+ if (typeof candidate === 'string') message = candidate;
14
+ } catch {
15
+ // Fixed fallback. The accessor's thrown value is not itself formatted.
16
+ }
17
+ }
18
+ let diagnostic = message;
19
+ try {
20
+ const stack = error?.stack;
21
+ if (typeof stack === 'string') diagnostic = stack;
22
+ } catch {
23
+ // Preserve the usable message when only stack access fails.
24
+ }
25
+ return {
26
+ message: boundLogMessage(message, 4096),
27
+ diagnostic: boundLogMessage(diagnostic),
28
+ };
29
+ }
@@ -0,0 +1,204 @@
1
+ // A bounded, ordered writer for the runner's private stdout protocol channel.
2
+ // Limits are local resource policies, not Firebase quotas. They count encoded
3
+ // pending frames (including the stream's in-flight write), not process-wide RSS.
4
+ import { performance } from 'node:perf_hooks';
5
+ import { MAX_FRAME_BYTES } from './protocol.mjs';
6
+
7
+ export const MAX_PENDING_BYTES = 32 * 1024 * 1024;
8
+ export const MAX_PENDING_FRAMES = 1024;
9
+ export const MAX_OUTPUT_WAIT_MS = 30_000;
10
+ export const OUTPUT_FINISH_MS = 1_000;
11
+
12
+ export class OutputError extends Error {
13
+ constructor(reason) {
14
+ super(`runner output failed (${reason})`);
15
+ this.name = 'OutputError';
16
+ }
17
+ }
18
+
19
+ function limit(value, maximum, name) {
20
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
21
+ throw new TypeError(`invalid output ${name}`);
22
+ }
23
+ return value;
24
+ }
25
+
26
+ export class FrameWriter {
27
+ #stream;
28
+ #write;
29
+ #onError;
30
+ #maxBytes;
31
+ #maxFrames;
32
+ #waitMs;
33
+ #finishMs;
34
+ #queue = [];
35
+ #current = null;
36
+ #bytes = 0;
37
+ #frames = 0;
38
+ #blocked = false;
39
+ #blockedAt = 0;
40
+ #writing = false;
41
+ #encoding = false;
42
+ #failed = false;
43
+ #closing = false;
44
+ #closeAt = Infinity;
45
+ #finishPromise;
46
+ #finishResolve;
47
+ #timer = null;
48
+
49
+ constructor(stream, {
50
+ onError,
51
+ maxBytes = MAX_PENDING_BYTES,
52
+ maxFrames = MAX_PENDING_FRAMES,
53
+ waitMs = MAX_OUTPUT_WAIT_MS,
54
+ finishMs = OUTPUT_FINISH_MS,
55
+ } = {}) {
56
+ if (typeof stream?.write !== 'function' || typeof stream?.on !== 'function' ||
57
+ typeof onError !== 'function') throw new TypeError('output stream and onError required');
58
+ this.#stream = stream;
59
+ // Capture before index.mjs redirects user stdout writes to stderr.
60
+ this.#write = stream.write.bind(stream);
61
+ this.#onError = onError;
62
+ this.#maxBytes = limit(maxBytes, MAX_PENDING_BYTES, 'bytes');
63
+ this.#maxFrames = limit(maxFrames, MAX_PENDING_FRAMES, 'frames');
64
+ this.#waitMs = limit(waitMs, MAX_OUTPUT_WAIT_MS, 'wait');
65
+ this.#finishMs = limit(finishMs, OUTPUT_FINISH_MS, 'finish wait');
66
+ stream.on('drain', () => {
67
+ if (this.#failed) return;
68
+ this.#blocked = false;
69
+ this.#pump();
70
+ });
71
+ // Keep the error listener after failure: a write callback can fail before
72
+ // the stream emits 'error'. Neither event may become an unhandled error.
73
+ stream.on('error', () => this.#fail('stream error'));
74
+ stream.on('close', () => this.#fail('stream closed'));
75
+ stream.on('finish', () => this.#fail('stream ended'));
76
+ }
77
+
78
+ get state() {
79
+ return Object.freeze({ pendingBytes: this.#bytes, pendingFrames: this.#frames,
80
+ backpressured: this.#blocked, failed: this.#failed, closing: this.#closing });
81
+ }
82
+
83
+ // true means admitted to the bounded queue, NOT delivered to the daemon.
84
+ send(message) {
85
+ if (this.#failed || this.#closing) return false;
86
+ if (this.#encoding) return this.#fail('reentrant serialization');
87
+ if (this.#frames >= this.#maxFrames) return this.#fail('frame queue limit');
88
+ let payload;
89
+ this.#encoding = true;
90
+ try {
91
+ const text = JSON.stringify(message);
92
+ if (typeof text !== 'string' || text[0] !== '{' || text.at(-1) !== '}') {
93
+ return this.#fail('message object');
94
+ }
95
+ const length = Buffer.byteLength(text, 'utf8');
96
+ if (length > MAX_FRAME_BYTES) return this.#fail('frame too large');
97
+ const prefix = `${length}\n`;
98
+ const size = Buffer.byteLength(prefix) + length;
99
+ if (this.#bytes + size > this.#maxBytes) return this.#fail('byte queue limit');
100
+ // One private Buffer/write per complete frame; never publish a length for
101
+ // a payload that fails size/serialization checks or replay a false write.
102
+ payload = Buffer.from(prefix + text, 'utf8');
103
+ } catch {
104
+ return this.#fail('serialization');
105
+ } finally {
106
+ this.#encoding = false;
107
+ }
108
+ if (this.#failed) return false;
109
+ this.#queue.push({ payload, size: payload.length, at: performance.now() });
110
+ this.#bytes += payload.length;
111
+ this.#frames += 1;
112
+ this.#pump();
113
+ return !this.#failed;
114
+ }
115
+
116
+ // Flush only already-admitted output on ordinary EOF/explicit shutdown.
117
+ // Does not wait for user callbacks, end process.stdout, or cancel side effects.
118
+ finish() {
119
+ if (this.#finishPromise) return this.#finishPromise;
120
+ this.#closing = true;
121
+ this.#closeAt = performance.now() + this.#finishMs;
122
+ this.#finishPromise = new Promise(resolve => { this.#finishResolve = resolve; });
123
+ if (this.#failed) this.#finishResolve(false);
124
+ else this.#pump();
125
+ return this.#finishPromise;
126
+ }
127
+
128
+ #fail(reason) {
129
+ if (this.#failed) return false;
130
+ this.#failed = true;
131
+ clearTimeout(this.#timer);
132
+ this.#timer = null;
133
+ this.#queue = [];
134
+ this.#current = null;
135
+ this.#bytes = 0;
136
+ this.#frames = 0;
137
+ this.#finishResolve?.(false);
138
+ // No raw payload or arbitrary error getters/stringification in diagnostics.
139
+ try { this.#onError(new OutputError(reason)); } catch { /* already failed */ }
140
+ return false;
141
+ }
142
+
143
+ #arm() {
144
+ clearTimeout(this.#timer);
145
+ this.#timer = null;
146
+ if (this.#failed) return;
147
+ const oldest = this.#current ?? this.#queue[0];
148
+ if (!oldest && !this.#blocked) {
149
+ if (this.#closing) this.#finishResolve?.(true);
150
+ return;
151
+ }
152
+ // Enqueues/drain/progress never renew the oldest frame's residence time.
153
+ let due = oldest ? oldest.at + this.#waitMs : Infinity;
154
+ if (this.#blocked) due = Math.min(due, this.#blockedAt + this.#waitMs);
155
+ due = Math.min(due, this.#closeAt);
156
+ const remaining = due - performance.now();
157
+ if (remaining <= 0) { this.#fail('deadline'); return; }
158
+ this.#timer = setTimeout(() => this.#arm(), Math.max(1, Math.ceil(remaining)));
159
+ // Intentionally referenced: pending output must not disappear on empty EOF.
160
+ }
161
+
162
+ #pump() {
163
+ if (this.#failed || this.#writing) return;
164
+ if (this.#stream.destroyed || this.#stream.writableEnded) {
165
+ this.#fail('stream unavailable'); return;
166
+ }
167
+ const pending = this.#current ?? this.#queue[0];
168
+ if (pending && (performance.now() >= pending.at + this.#waitMs ||
169
+ performance.now() >= this.#closeAt)) {
170
+ this.#fail('deadline'); return;
171
+ }
172
+ this.#writing = true;
173
+ try {
174
+ while (!this.#failed && !this.#current && !this.#blocked && this.#queue.length) {
175
+ const entry = this.#queue.shift();
176
+ this.#current = entry;
177
+ let returned;
178
+ try {
179
+ returned = this.#write(entry.payload, error => {
180
+ if (this.#failed) return;
181
+ if (error) { this.#fail('write callback'); return; }
182
+ if (performance.now() >= entry.at + this.#waitMs ||
183
+ performance.now() >= this.#closeAt) { this.#fail('deadline'); return; }
184
+ if (this.#current !== entry) { this.#fail('write callback order'); return; }
185
+ this.#current = null;
186
+ this.#bytes -= entry.size;
187
+ this.#frames -= 1;
188
+ this.#pump();
189
+ });
190
+ } catch {
191
+ this.#fail('write'); break;
192
+ }
193
+ if (returned !== true && returned !== false) { this.#fail('write return'); break; }
194
+ if (returned === false) {
195
+ this.#blocked = true;
196
+ this.#blockedAt = entry.at;
197
+ }
198
+ }
199
+ } finally {
200
+ this.#writing = false;
201
+ }
202
+ this.#arm();
203
+ }
204
+ }
@@ -0,0 +1,280 @@
1
+ // Runtime -> runner: canonical decimal byte length, LF, UTF-8 JSON object.
2
+ // Keep the byte cap in sync with fireemu-adapter-functions/src/protocol.rs.
3
+ // This is a trusted local pipe, not an authentication or arbitrary-code sandbox.
4
+ import { TextDecoder } from 'node:util';
5
+ import { performance } from 'node:perf_hooks';
6
+ import { setTimeout, clearTimeout } from 'node:timers';
7
+
8
+ export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
9
+ // Local pipe safety policies, not Firebase invocation deadlines or quotas.
10
+ export const MAX_INPUT_FRAME_WAIT_MS = 30_000;
11
+ export const MAX_ACTIVE_INVOCATIONS = 4096;
12
+ export const MAX_ACTIVE_INVOCATION_BYTES = 64 * 1024 * 1024;
13
+ const MAX_HEADER_BYTES = String(MAX_FRAME_BYTES).length;
14
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
15
+
16
+ export class ProtocolError extends Error {
17
+ constructor(reason) {
18
+ // Fixed diagnostics only: JSON.parse errors can include user payload bytes.
19
+ super(`invalid runner frame (${reason})`);
20
+ this.name = 'ProtocolError';
21
+ }
22
+ }
23
+
24
+ function object(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ }
27
+ function text(value) {
28
+ return typeof value === 'string' && value.length > 0;
29
+ }
30
+
31
+ function decodeMessage(payload) {
32
+ let message;
33
+ try {
34
+ message = JSON.parse(decoder.decode(payload));
35
+ } catch {
36
+ throw new ProtocolError('UTF-8 or JSON');
37
+ }
38
+ if (!object(message)) throw new ProtocolError('message object');
39
+ if (message.type === 'shutdown') return message;
40
+ if (message.type !== 'invoke' || !text(message.invocationId) ||
41
+ !text(message.function) || !text(message.trigger) || !object(message.event) ||
42
+ (message.entryPoint !== undefined && !text(message.entryPoint))) {
43
+ throw new ProtocolError('message fields');
44
+ }
45
+ return message;
46
+ }
47
+
48
+ // At most one capped body allocation is retained. Chunking never causes repeated
49
+ // concatenation of the growing body, and an over-limit header fails before that
50
+ // allocation. Handlers run only after a complete frame has been decoded.
51
+ export class FrameDecoder {
52
+ #onFrame;
53
+ #length = 0;
54
+ #digits = 0;
55
+ #body = null;
56
+ #offset = 0;
57
+ #ended = false;
58
+ #failed = false;
59
+ #now;
60
+ #waitMs;
61
+ #lastTime = null;
62
+ #deadline = null;
63
+
64
+ constructor(onFrame, { frameWaitMs = MAX_INPUT_FRAME_WAIT_MS,
65
+ now = () => performance.now() } = {}) {
66
+ if (typeof onFrame !== 'function' || typeof now !== 'function') {
67
+ throw new TypeError('onFrame and now must be functions');
68
+ }
69
+ if (!Number.isSafeInteger(frameWaitMs) || frameWaitMs < 1 ||
70
+ frameWaitMs > MAX_INPUT_FRAME_WAIT_MS) throw new TypeError('invalid input frame wait');
71
+ this.#onFrame = onFrame;
72
+ this.#waitMs = frameWaitMs;
73
+ this.#now = now;
74
+ }
75
+
76
+ #time() {
77
+ let value;
78
+ try { value = this.#now(); } catch { throw new ProtocolError('input clock'); }
79
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 ||
80
+ (this.#lastTime !== null && value < this.#lastTime)) {
81
+ throw new ProtocolError('input clock');
82
+ }
83
+ this.#lastTime = value;
84
+ return value;
85
+ }
86
+
87
+ #check(now = this.#time()) {
88
+ if (this.#deadline !== null && now >= this.#deadline) {
89
+ throw new ProtocolError('input frame deadline');
90
+ }
91
+ }
92
+
93
+ #fail(error) {
94
+ this.#failed = true;
95
+ this.#body = null;
96
+ this.#deadline = null;
97
+ throw error;
98
+ }
99
+
100
+ // Null when between frames. Partial headers and bodies share one deadline,
101
+ // measured from the first observed byte, never renewed by progress.
102
+ remainingTime() {
103
+ if (this.#failed) throw new ProtocolError('closed');
104
+ if (this.#ended || this.#deadline === null) return null;
105
+ try {
106
+ const now = this.#time();
107
+ this.#check(now);
108
+ return this.#deadline - now;
109
+ } catch (error) { return this.#fail(error); }
110
+ }
111
+
112
+ push(chunk) {
113
+ if (this.#failed) throw new ProtocolError('closed');
114
+ if (this.#ended) return;
115
+ try {
116
+ if (!Buffer.isBuffer(chunk)) throw new ProtocolError('non-byte input');
117
+ if (chunk.length === 0) { this.remainingTime(); return; }
118
+ // All bytes in a coalesced chunk arrived together. A slow synchronous
119
+ // onFrame must not give its trailing frame a fresh receive-time budget.
120
+ const receivedAt = this.#time();
121
+ this.#check(receivedAt);
122
+ let at = 0;
123
+ while (at < chunk.length && !this.#ended) {
124
+ if (this.#deadline === null) {
125
+ this.#deadline = receivedAt + this.#waitMs;
126
+ if (!Number.isFinite(this.#deadline) || this.#deadline <= receivedAt) {
127
+ throw new ProtocolError('input clock');
128
+ }
129
+ }
130
+ this.#check();
131
+ if (this.#body === null) {
132
+ const byte = chunk[at++];
133
+ if (byte === 10) {
134
+ if (this.#digits === 0) throw new ProtocolError('empty length');
135
+ this.#body = Buffer.allocUnsafe(this.#length);
136
+ } else {
137
+ if (byte < 48 || byte > 57 || (this.#digits === 0 && byte === 48)) {
138
+ throw new ProtocolError('decimal length');
139
+ }
140
+ this.#digits += 1;
141
+ this.#length = this.#length * 10 + byte - 48;
142
+ if (this.#digits > MAX_HEADER_BYTES || this.#length > MAX_FRAME_BYTES) {
143
+ throw new ProtocolError('frame too large');
144
+ }
145
+ }
146
+ } else {
147
+ const count = Math.min(chunk.length - at, this.#length - this.#offset);
148
+ chunk.copy(this.#body, this.#offset, at, at + count);
149
+ at += count;
150
+ this.#offset += count;
151
+ if (this.#offset !== this.#length) continue;
152
+ const message = decodeMessage(this.#body);
153
+ // Timers can run late. Check after decoding, before any user callback,
154
+ // so a late final chunk cannot race the timer into successful dispatch.
155
+ this.#check();
156
+ const payloadBytes = this.#length;
157
+ this.#body = null;
158
+ this.#offset = 0;
159
+ this.#length = 0;
160
+ this.#digits = 0;
161
+ this.#deadline = null;
162
+ // Byte accounting is the actual received payload length, not a
163
+ // caller-supplied field or a second JSON serialization.
164
+ if (this.#onFrame(message, payloadBytes) === false) this.#ended = true;
165
+ }
166
+ }
167
+ } catch (error) { this.#fail(error); }
168
+ }
169
+
170
+ end() {
171
+ if (this.#failed) throw new ProtocolError('closed');
172
+ if (this.#ended) return;
173
+ this.#ended = true;
174
+ if (this.#digits !== 0 || this.#body !== null) {
175
+ this.#fail(new ProtocolError('truncated'));
176
+ }
177
+ }
178
+ }
179
+
180
+ // Keep the active callback and environment-queue inputs bounded independently
181
+ // of the output queue. A lease releases once, including on callback rejection.
182
+ // Counts measure encoded input payload bytes, not V8 heap size or retained user
183
+ // references after a callback returns. HTTP callbacks are not part of this pipe.
184
+ export class InvocationBudget {
185
+ #active = new Map();
186
+ #bytes = 0;
187
+ #failed = false;
188
+ #maxCount;
189
+ #maxBytes;
190
+
191
+ constructor({ maxCount = MAX_ACTIVE_INVOCATIONS,
192
+ maxBytes = MAX_ACTIVE_INVOCATION_BYTES } = {}) {
193
+ if (!Number.isSafeInteger(maxCount) || maxCount < 1 || maxCount > MAX_ACTIVE_INVOCATIONS ||
194
+ !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ACTIVE_INVOCATION_BYTES) {
195
+ throw new TypeError('invalid invocation limits');
196
+ }
197
+ this.#maxCount = maxCount;
198
+ this.#maxBytes = maxBytes;
199
+ }
200
+
201
+ get state() {
202
+ return Object.freeze({ count: this.#active.size, payloadBytes: this.#bytes, failed: this.#failed });
203
+ }
204
+
205
+ #fail(reason) {
206
+ this.#failed = true;
207
+ throw new ProtocolError(reason);
208
+ }
209
+
210
+ reserve(id, payloadBytes) {
211
+ if (this.#failed) throw new ProtocolError('closed');
212
+ if (!text(id) || !Number.isSafeInteger(payloadBytes) || payloadBytes < 1 ||
213
+ payloadBytes > MAX_FRAME_BYTES) this.#fail('invocation accounting');
214
+ if (this.#active.has(id)) this.#fail('duplicate active invocation');
215
+ if (this.#active.size >= this.#maxCount) this.#fail('active invocation count');
216
+ if (this.#bytes + payloadBytes > this.#maxBytes) this.#fail('active invocation bytes');
217
+ const ticket = { payloadBytes };
218
+ this.#active.set(id, ticket);
219
+ this.#bytes += payloadBytes;
220
+ let released = false;
221
+ return () => {
222
+ if (released) return;
223
+ released = true;
224
+ // An old lease cannot release a later invocation that reuses the ID.
225
+ if (this.#active.get(id) !== ticket) return;
226
+ this.#active.delete(id);
227
+ this.#bytes -= payloadBytes;
228
+ };
229
+ }
230
+ }
231
+
232
+ export function readFrames(input, onFrame, onEnd, onError, options = {}) {
233
+ let stopped = false;
234
+ let timer = null;
235
+ const disarm = () => { clearTimeout(timer); timer = null; };
236
+ const pause = () => { try { input.pause(); } catch { /* already stopped */ } };
237
+ const stop = error => {
238
+ if (stopped) return;
239
+ stopped = true;
240
+ disarm();
241
+ pause();
242
+ // No parser message (or raw bytes) may widen this fixed diagnostic.
243
+ onError(error instanceof ProtocolError ? error : new ProtocolError('dispatch'));
244
+ };
245
+ const frames = new FrameDecoder((message, payloadBytes) => {
246
+ const keepReading = onFrame(message, payloadBytes);
247
+ if (keepReading === false) {
248
+ stopped = true;
249
+ disarm();
250
+ pause();
251
+ }
252
+ return keepReading;
253
+ }, options);
254
+ const arm = () => {
255
+ disarm();
256
+ if (stopped) return;
257
+ let remaining;
258
+ try { remaining = frames.remainingTime(); } catch (error) { stop(error); return; }
259
+ if (remaining === null) return; // Idle runner and in-flight user work have no frame timer.
260
+ timer = setTimeout(arm, Math.max(1, Math.ceil(remaining)));
261
+ // Referenced while a partial frame is held; never refresh from the latest byte.
262
+ };
263
+ input.on('data', chunk => {
264
+ if (stopped) return;
265
+ try { frames.push(chunk); } catch (error) { stop(error); return; }
266
+ arm();
267
+ });
268
+ input.on('end', () => {
269
+ if (stopped) return;
270
+ try { frames.end(); } catch (error) { stop(error); return; }
271
+ stopped = true;
272
+ disarm();
273
+ onEnd();
274
+ });
275
+ input.on('error', () => stop(new ProtocolError('input stream')));
276
+ // A close without end is not a clean EOF (for example, a destroyed pipe).
277
+ input.on('close', () => {
278
+ if (!stopped) stop(new ProtocolError('input closed'));
279
+ });
280
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fireemu/win32-x64",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The fireemu daemon for win32-x64",
5
5
  "homepage": "https://github.com/t-k/fireemu#readme",
6
6
  "repository": {