fix(v5): Das Update installierte nie — die eigene Leine erschlug den Installer
Ampel / ampel (push) Successful in 1m41s

Commander: "Rippy wird zwar sauber beendet aber startet NICHT neu in der
neuen version."

Ursache, auf drei Beinen belegt:

1. Bibliothek: electron-updater startet den Installer per
   spawn(..., { detached: true }) (BaseUpdater.js, an der installierten
   Fassung nachgelesen). `detached` setzt unter Windows KEIN Breakaway.
2. Leine: leine.ts setzt KILL_ON_JOB_CLOSE, und Kinder erben die
   Mitgliedschaft. Der Installer war damit Mitglied der Arbeitsgruppe.
3. Platte: pending\RippySetup-5.1.3.exe lag geladen da (18:11:47),
   installiert war weiter 5.1.2. Der Download klappte, nur die
   Installation nicht.

Der Installer starb also in genau der Sekunde, in der Rippy sich
beendete, um ihm Platz zu machen.

Fix — die Leine bleibt, der Installer klinkt sich aus:

* leine.ts setzt zusaetzlich JOB_OBJECT_LIMIT_BREAKAWAY_OK. Das aendert
  fuer sich genommen NICHTS: Kinder erben weiter, ausser sie verlangen
  das Ausklinken selbst. Kern, makemkvcon und HandBrakeCLI bleiben
  angeleint, die rc10-Waisen-Falle (Paragraph 3.3) ist unveraendert zu.
* Neu: ausserhalbDerLeineStarten() startet EIN Programm per CreateProcessW
  mit CREATE_BREAKAWAY_FROM_JOB. Liegt in leine.ts, weil dort die
  Arbeitsgruppe verwaltet wird — und weil Waechter R1 koffi nur dort und
  in kern/laufwerk/win32.ts erlaubt.
* update.ts installiert jetzt SELBST: autoInstallOnAppQuit ist aus, der
  Installerpfad kommt aus downloadUpdate() (laut AppUpdater.d.ts "Paths to
  downloaded files"), die Argumente sind die von electron-updater
  gemessenen (--updated /S --force-run). Gestartet wird beim Beenden
  (will-quit) und ueber den Knopf.
* Neu kommandozeile.ts (pur, ohne koffi/Electron): CreateProcessW nimmt
  EINE Zeichenkette. Ohne die Regeln von CommandLineToArgvW zerfiele ein
  Pfad wie "C:\Users\Tobi Neu\..." in zwei Argumente.

BEWIESEN, nicht behauptet — neu: beweise/leine-breakaway.js

  OHNE Ausklinken (so macht es electron-updater)
    lebt nach dem Tod des Elternprozesses: NEIN  -> WIE ERWARTET
  MIT Ausklinken (so macht es Rippy ab 5.1.4)
    lebt nach dem Tod des Elternprozesses: JA    -> WIE ERWARTET

Der erste Messlauf zeigte ein falsches Negativ: Der Enkel starb an der
sterbenden KONSOLE des Elternprozesses, nicht an der Arbeitsgruppe. Erst
mit CREATE_NO_WINDOW (eigene Konsole) misst der Versuch, was er messen
soll. Diese Lehre steht als Kommentar im Code und der Flag ist auch im
echten Aufruf gesetzt.

237 Tests gruen (vorher 230), Typpruefung sauber. Version 5.1.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-01 18:27:36 +02:00
co-authored by Claude Opus 5
parent 1d53e8526f
commit 051cab3afa
9 changed files with 385 additions and 31 deletions
+109
View File
@@ -0,0 +1,109 @@
// MESSUNG: Überlebt ein ausdrücklich ausgeklinktes Kind den Tod seines
// Elternprozesses — und stirbt ein normales Kind weiterhin mit?
//
// Anlass (01.09.2026): Das Update auf 5.1.3 lief nicht. Rippy beendete
// sauber, aber der Installer startete nie. Verdacht: Die Prozess-Leine
// (§ 3.3, KILL_ON_JOB_CLOSE) erschlägt den Installer, weil
// electron-updater ihn per spawn(…, { detached: true }) startet und
// `detached` unter Windows KEIN Breakaway setzt.
//
// Aufbau (drei Läufe, der äußere ruft die inneren):
// node beweise/leine-breakaway.js
// -> startet zweimal sich selbst als „innen":
// innen-ohne : Arbeitsgruppe NUR mit KILL_ON_JOB_CLOSE, Enkel normal
// innen-mit : Arbeitsgruppe zusätzlich mit BREAKAWAY_OK,
// Enkel mit CREATE_BREAKAWAY_FROM_JOB
// Beide beenden sich sofort. Danach schaut der äußere Lauf nach,
// welcher Enkel noch lebt.
//
// Erwartung: ohne Ausklinken stirbt der Enkel, mit Ausklinken lebt er.
//
// Ausführen: node beweise/leine-breakaway.js
const { execFileSync, spawnSync } = require('node:child_process')
const path = require('node:path')
const koffi = require(path.join(__dirname, '..', 'rippy-windows', 'node_modules', 'koffi'))
const JOB_KLASSE = 9 // JobObjectExtendedLimitInformation
const KILL_ON_JOB_CLOSE = 0x2000
const BREAKAWAY_OK = 0x0800
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
const CREATE_NO_WINDOW = 0x08000000
const STRUKTUR_GROESSE = 144
const LIMIT_FLAGS_OFFSET = 16
const STARTUPINFO_GROESSE = 104
const PROCESS_INFORMATION_GROESSE = 24
const kernel32 = koffi.load('kernel32.dll')
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *a, str16 n)')
const SetInformationJobObject = kernel32.func(
'bool __stdcall SetInformationJobObject(void *j, int k, void *i, uint32_t l)',
)
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *j, void *p)')
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const CreateProcessW = kernel32.func(
'bool __stdcall CreateProcessW(str16 an, void *cl, void *pa, void *ta, bool ih, uint32_t cf, void *env, str16 cd, void *si, void *pi)',
)
/** Ein Enkel, der 40 Sekunden lebt — lange genug zum Nachsehen. */
const ENKEL = 'C:\\Windows\\System32\\cmd.exe'
const ENKEL_ZEILE = '"C:\\Windows\\System32\\cmd.exe" /c ping -n 40 127.0.0.1'
function innen(mitBreakaway) {
const job = CreateJobObjectW(null, null)
const info = Buffer.alloc(STRUKTUR_GROESSE)
info.writeUInt32LE(KILL_ON_JOB_CLOSE | (mitBreakaway ? BREAKAWAY_OK : 0), LIMIT_FLAGS_OFFSET)
SetInformationJobObject(job, JOB_KLASSE, info, STRUKTUR_GROESSE)
AssignProcessToJobObject(job, GetCurrentProcess())
const si = Buffer.alloc(STARTUPINFO_GROESSE)
si.writeUInt32LE(STARTUPINFO_GROESSE, 0)
const pi = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
const zeile = Buffer.from(ENKEL_ZEILE + '\0', 'utf16le')
// CREATE_NO_WINDOW gibt dem Enkel eine EIGENE (unsichtbare) Konsole.
// Ohne das teilt er sich die Konsole des Elternprozesses und stirbt mit
// IHR, nicht mit der Arbeitsgruppe — der erste Messlauf am 01.09.2026
// lief genau in diese Falle und zeigte fälschlich „Ausklinken wirkt
// nicht". Beide Fälle bekommen es, damit der Vergleich fair bleibt.
const flags = CREATE_NO_WINDOW | (mitBreakaway ? CREATE_BREAKAWAY_FROM_JOB : 0)
const ok = CreateProcessW(ENKEL, zeile, null, null, false, flags, null, null, si, pi)
if (!ok) {
console.log(`PID=0 FEHLER=${GetLastError()}`)
return
}
// dwProcessId steht in PROCESS_INFORMATION an Offset 16.
console.log(`PID=${pi.readUInt32LE(16)} FEHLER=0`)
}
function lebt(pid) {
const ergebnis = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8' })
return (ergebnis.stdout || '').includes(String(pid))
}
function aussen() {
const faelle = [
['OHNE Ausklinken (so macht es electron-updater)', 'innen-ohne', false],
['MIT Ausklinken (so macht es Rippy ab 5.1.4)', 'innen-mit', true],
]
for (const [name, modus, erwartetLebend] of faelle) {
const ausgabe = execFileSync(process.execPath, [__filename, modus], { encoding: 'utf8' }).trim()
const pid = Number(/PID=(\d+)/.exec(ausgabe)?.[1] ?? 0)
const fehler = Number(/FEHLER=(\d+)/.exec(ausgabe)?.[1] ?? 0)
// Der innere Lauf ist hier schon beendet — die Arbeitsgruppe ist also
// zugeschnappt, falls sie zuschnappt.
const nochDa = pid > 0 ? lebt(pid) : false
const urteil = nochDa === erwartetLebend ? 'WIE ERWARTET' : 'ABWEICHUNG'
console.log(
`${name}\n Enkel-PID ${pid || '—'}${fehler ? ` (Win32-Fehler ${fehler})` : ''}` +
`\n lebt nach dem Tod des Elternprozesses: ${nochDa ? 'JA' : 'NEIN'} -> ${urteil}\n`,
)
if (nochDa) spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'])
}
}
const modus = process.argv[2] ?? ''
if (modus === 'innen-ohne') innen(false)
else if (modus === 'innen-mit') innen(true)
else aussen()