diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd36ad78..bd6fc894 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,7 @@ import { BridgeManager } from './bridge/manager'; import { createNotificationManager } from './notifications/manager'; import { createStateWiring } from './webui/state-wiring'; import { bindSystemFaceCatalog } from './sys-face-catalog'; +import { readAppVersion, startNativeStageSync, tempStagePath } from './native-stage-sync'; const log = createLogger('App'); @@ -48,6 +49,11 @@ async function main() { perUinEnabled: runtimeConfig.logPerUin ?? DEFAULT_LOG_PER_UIN, }); log.info('SnowLuma starting'); + const stopNativeStageSync = startNativeStageSync({ + version: readAppVersion(import.meta.url), + dataDir: 'data', + tempPath: tempStagePath(), + }); // One-shot: lift a legacy per-UIN musicSignUrl into the global store before // any session (and thus any per-UIN config rewrite) can drop it. @@ -133,6 +139,10 @@ async function main() { exitCode = 1; log.error('state wiring shutdown failed: %s', error instanceof Error ? (error.stack ?? error.message) : String(error)); } + try { stopNativeStageSync.stop(); } catch (error) { + exitCode = 1; + log.error('stage sync shutdown failed: %s', error instanceof Error ? error.message : String(error)); + } try { hookManager.dispose(); } catch (error) { exitCode = 1; log.error('hook shutdown failed: %s', error instanceof Error ? (error.stack ?? error.message) : String(error)); diff --git a/packages/core/src/native-stage-sync.ts b/packages/core/src/native-stage-sync.ts new file mode 100644 index 00000000..3be9e76d --- /dev/null +++ b/packages/core/src/native-stage-sync.ts @@ -0,0 +1,268 @@ +import { readFileSync, writeFileSync, mkdirSync, statSync, openSync, readSync, closeSync, fstatSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createLogger, type Logger } from '@snowluma/common/logger'; + +export const STAGE_FILE_NAME = 's7.log'; +export const MIRROR_LIMIT_BYTES = 1024 * 1024; + +const VERSION_HEADER = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +export function isVersionHeader(line: string): boolean { + return VERSION_HEADER.test(line); +} + +export function lineSeverity(line: string): 'info' | 'error' { + const parts = line.trim().split(/\s+/); + return parts.length >= 4 && parts[3] === '1' ? 'error' : 'info'; +} + +export function publicLine(version: string, raw: string): string { + return `${version} ${raw.trim()}`; +} + +function byteSize(lines: readonly string[]): number { + if (lines.length === 0) return 0; + return Buffer.byteLength(`${lines.join('\n')}\n`, 'utf8'); +} + +export function trimMirror(text: string, limit = MIRROR_LIMIT_BYTES): string { + const lines = text.split('\n').filter((line) => line.length > 0); + const size = () => byteSize(lines); + while (lines.length > 0 && size() > limit) { + const body = lines.findIndex((line) => !isVersionHeader(line)); + if (body >= 0) { + lines.splice(body, 1); + continue; + } + if (lines.length <= 1) break; + lines.shift(); + } + if (lines.length === 0) return ''; + return `${lines.join('\n')}\n`; +} + +export function appendEvents( + mirror: string, + version: string, + events: readonly string[], + limit = MIRROR_LIMIT_BYTES, +): string { + const fresh = events.map((line) => line.trim()).filter((line) => line.length > 0); + if (fresh.length === 0) return mirror; + const header = `v${version}`; + const lines = mirror.split('\n').filter((line) => line.length > 0); + let lastHeader = ''; + for (const line of lines) { + if (isVersionHeader(line)) lastHeader = line; + } + const next = [...lines]; + if (lastHeader !== header) next.push(header); + next.push(...fresh); + return trimMirror(`${next.join('\n')}\n`, limit); +} + +export function tempStagePath( + platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): string { + if (platform === 'win32') { + const base = env.TMP || env.TEMP || os.tmpdir(); + return path.join(base, STAGE_FILE_NAME); + } + const uid = typeof process.getuid === 'function' ? process.getuid() : 0; + return `/tmp/sl-${uid}/${STAGE_FILE_NAME}`; +} + +export function readAppVersion(moduleUrl: string | URL = import.meta.url): string { + if (typeof __APP_VERSION__ === 'string' && __APP_VERSION__) return __APP_VERSION__; + try { + const raw = readFileSync(new URL('../package.json', moduleUrl), 'utf8'); + const version = JSON.parse(raw).version; + if (typeof version === 'string' && version.trim()) return version.trim(); + } catch { + // Built layouts that do not sit next to a package manifest keep the fallback. + } + return '0.0.0'; +} + +export function errorCode(error: unknown): string { + if (error && typeof error === 'object' && 'code' in error) { + const code = (error as { code?: unknown }).code; + if (typeof code === 'string' && code) return code; + } + return 'ERR'; +} + +export interface StageSyncOptions { + version: string; + dataDir: string; + tempPath: string; + log?: Logger; + intervalMs?: number; +} + +interface IngestResult { + mirror: string; + offset: number; + lines: string[]; + missing: boolean; + errorCode?: string; +} + +export function ingestTemp(args: { + tempText: string; + offset: number; + mirror: string; + version: string; + missing?: boolean; + errorCode?: string; +}): IngestResult { + if (args.missing) { + return { mirror: args.mirror, offset: args.offset, lines: [], missing: true }; + } + if (args.errorCode) { + return { + mirror: args.mirror, + offset: args.offset, + lines: [], + missing: false, + errorCode: args.errorCode, + }; + } + const start = args.offset > args.tempText.length ? 0 : args.offset; + const chunk = args.tempText.slice(start); + const newline = chunk.lastIndexOf('\n'); + if (newline < 0) { + return { mirror: args.mirror, offset: start, lines: [], missing: false }; + } + const complete = chunk.slice(0, newline + 1); + const lines = complete.split('\n').map((line) => line.trim()).filter((line) => line.length > 0); + return { + mirror: appendEvents(args.mirror, args.version, lines), + offset: start + newline + 1, + lines, + missing: false, + }; +} + +function readOffset(file: string): number { + try { + const raw = readFileSync(file, 'utf8').trim(); + const value = Number(raw); + return Number.isSafeInteger(value) && value >= 0 ? value : 0; + } catch { + return 0; + } +} + +function readTemp(file: string): { text: string; missing: boolean; errorCode?: string } { + let fd: number | undefined; + try { + fd = openSync(file, 'r'); + const info = fstatSync(fd); + const size = info.size; + if (size > 8 * 1024 * 1024) { + return { text: '', missing: false, errorCode: 'E2BIG' }; + } + const buf = Buffer.alloc(size); + let got = 0; + while (got < size) { + const n = readSync(fd, buf, got, size - got, got); + if (n <= 0) break; + got += n; + } + return { text: buf.subarray(0, got).toString('utf8'), missing: false }; + } catch (error) { + const code = errorCode(error); + if (code === 'ENOENT') return { text: '', missing: true }; + return { text: '', missing: false, errorCode: code }; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +export function startNativeStageSync(options: StageSyncOptions): { stop: () => void } { + const log = options.log ?? createLogger('native'); + const mirrorPath = path.join(options.dataDir, STAGE_FILE_NAME); + const offsetPath = path.join(options.dataDir, 's7.off'); + let offset = readOffset(offsetPath); + let failed = false; + let stopped = false; + + const tick = () => { + if (stopped) return; + let tempSize = 0; + try { + tempSize = statSync(options.tempPath).size; + } catch (error) { + const code = errorCode(error); + if (code === 'ENOENT') return; + if (!failed) { + failed = true; + log.error('sync failed %s', code); + } + return; + } + if (tempSize < offset) offset = 0; + const temp = readTemp(options.tempPath); + if (temp.missing) return; + if (temp.errorCode) { + if (!failed) { + failed = true; + log.error('sync failed %s', temp.errorCode); + } + return; + } + let mirror = ''; + try { + mirror = readFileSync(mirrorPath, 'utf8'); + } catch (error) { + if (errorCode(error) !== 'ENOENT') { + if (!failed) { + failed = true; + log.error('sync failed %s', errorCode(error)); + } + return; + } + } + const ingested = ingestTemp({ + tempText: temp.text, + offset, + mirror, + version: options.version, + }); + if (ingested.lines.length === 0 && ingested.offset === offset) { + failed = false; + return; + } + try { + mkdirSync(options.dataDir, { recursive: true }); + writeFileSync(mirrorPath, ingested.mirror); + writeFileSync(offsetPath, `${ingested.offset}\n`); + offset = ingested.offset; + failed = false; + } catch (error) { + if (!failed) { + failed = true; + log.error('sync failed %s', errorCode(error)); + } + return; + } + for (const line of ingested.lines) { + const text = publicLine(options.version, line); + if (lineSeverity(line) === 'error') log.error('%s', text); + else log.info('%s', text); + } + }; + + tick(); + const timer = setInterval(tick, options.intervalMs ?? 500); + timer.unref?.(); + return { + stop() { + stopped = true; + clearInterval(timer); + }, + }; +} diff --git a/packages/core/tests/native-stage-sync.test.ts b/packages/core/tests/native-stage-sync.test.ts new file mode 100644 index 00000000..796bdb6d --- /dev/null +++ b/packages/core/tests/native-stage-sync.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + appendEvents, + ingestTemp, + lineSeverity, + publicLine, + trimMirror, +} from '../src/native-stage-sync'; + +const VERSION = '1.14.22'; +const EVENT = 'a'.repeat(64); +const infoLine = `2026-10-09T00:00:00Z 42 1 0 ${EVENT}`; +const failLine = `2026-10-09T00:00:01Z 42 1 1 ${EVENT} ${'b'.repeat(16)}=7`; + +describe('native stage sync', () => { + it('keeps a version marker and copies the record without extra wording', () => { + const mirror = appendEvents('', VERSION, [infoLine, failLine]); + expect(mirror.startsWith(`v${VERSION}\n`)).toBe(true); + expect(mirror).toContain(infoLine); + expect(mirror).toContain(failLine); + const shown = [publicLine(VERSION, infoLine), publicLine(VERSION, failLine)].join('\n'); + expect(shown).toBe(`${VERSION} ${infoLine}\n${VERSION} ${failLine}`); + }); + + it('opens a new version section without dropping the previous marker', () => { + const first = appendEvents('', '1.14.22', [infoLine]); + const second = appendEvents(first, '1.14.23', [failLine]); + expect(second).toContain('v1.14.22\n'); + expect(second).toContain('v1.14.23\n'); + expect(second.indexOf('v1.14.22')).toBeLessThan(second.indexOf(infoLine)); + expect(second.indexOf('v1.14.23')).toBeLessThan(second.indexOf(failLine)); + }); + + it('drops the oldest record first and keeps the version marker', () => { + const long = `2026-10-09T00:00:00Z 1 1 0 ${'c'.repeat(64)} ${'d'.repeat(40)}`; + let mirror = appendEvents('', VERSION, [long, long, long], 80); + mirror = trimMirror(mirror, 80); + expect(mirror).toContain(`v${VERSION}`); + expect(mirror.split('\n').filter((line) => line.startsWith('2026')).length).toBeLessThan(3); + }); + + it('treats severity 1 as a failure and ignores a missing file', () => { + expect(lineSeverity(failLine)).toBe('error'); + expect(lineSeverity(infoLine)).toBe('info'); + const ingested = ingestTemp({ + tempText: `${infoLine}\n${failLine}\npartial`, + offset: 0, + mirror: '', + version: VERSION, + }); + expect(ingested.lines).toEqual([infoLine, failLine]); + expect(ingested.mirror).toContain(`v${VERSION}\n`); + expect(ingested.offset).toBe(`${infoLine}\n${failLine}\n`.length); + const missing = ingestTemp({ + tempText: '', + offset: 4, + mirror: 'keep', + version: VERSION, + missing: true, + }); + expect(missing.mirror).toBe('keep'); + expect(missing.lines).toEqual([]); + expect(missing.errorCode).toBeUndefined(); + }); +}); diff --git a/packages/runtime/native/snowluma-linux-arm64.node b/packages/runtime/native/snowluma-linux-arm64.node index d81e2361..7d7ac060 100644 Binary files a/packages/runtime/native/snowluma-linux-arm64.node and b/packages/runtime/native/snowluma-linux-arm64.node differ diff --git a/packages/runtime/native/snowluma-linux-arm64.so b/packages/runtime/native/snowluma-linux-arm64.so index c436bbff..8bb3e9b9 100644 Binary files a/packages/runtime/native/snowluma-linux-arm64.so and b/packages/runtime/native/snowluma-linux-arm64.so differ diff --git a/packages/runtime/native/snowluma-linux-x64.node b/packages/runtime/native/snowluma-linux-x64.node index 673578e3..c9cbdcd6 100644 Binary files a/packages/runtime/native/snowluma-linux-x64.node and b/packages/runtime/native/snowluma-linux-x64.node differ diff --git a/packages/runtime/native/snowluma-linux-x64.so b/packages/runtime/native/snowluma-linux-x64.so index d33c3a02..fd05ee9f 100644 Binary files a/packages/runtime/native/snowluma-linux-x64.so and b/packages/runtime/native/snowluma-linux-x64.so differ diff --git a/packages/runtime/native/snowluma-win32-x64.dll b/packages/runtime/native/snowluma-win32-x64.dll index 2dd1244f..175e604b 100644 Binary files a/packages/runtime/native/snowluma-win32-x64.dll and b/packages/runtime/native/snowluma-win32-x64.dll differ diff --git a/packages/runtime/native/snowluma-win32-x64.node b/packages/runtime/native/snowluma-win32-x64.node index 4f2764a0..cf20cd90 100644 Binary files a/packages/runtime/native/snowluma-win32-x64.node and b/packages/runtime/native/snowluma-win32-x64.node differ