[GH-ISSUE #6108] [Bug] export-web-scripts leaves partial output after SIGINT #3739

Closed
opened 2026-10-31 00:43:33 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6108

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

CLI

Observed behavior

Sending real SIGINT to the built export-web-scripts CLI during file publication terminates the process and leaves owned partial output. Two executions each retained one of 160 script files, no manifest.json, and no structured result. Retrying the same output directory fails because it already exists. The uninterrupted control exported all 160 scripts with a manifest and status 0.

Reproduction

Use the built source checkout with its locked dependencies. Set REA_CANCEL_REPRO_DIR to an existing disposable directory. Generate a valid saved capture with the repository's synthetic browser-scenario fixture:

node --import tsx --input-type=module <<'JS'
import {writeFile} from 'node:fs/promises';
import {join} from 'node:path';
import {scriptScenarioFixture} from './tests/fixtures/webScriptCapture.ts';
const scripts = Array.from({length:160},(_,i)=>({
  url:`https://fixture.test/app/script-${i}.js`,
  bytes:Buffer.from(`export const marker${i} = ${i};\n`+' '.repeat(65536)),
}));
await writeFile(join(process.env.REA_CANCEL_REPRO_DIR,'capture.json'),
  JSON.stringify(scriptScenarioFixture(scripts)));
JS

Run this driver from the checkout root. It waits for actual script output before sending the signal; it does not modify REA or replace filesystem operations. Use an empty reproduction directory so the three destination names are absent.

import json, os, pathlib, signal, subprocess, time
root = pathlib.Path(os.environ['REA_CANCEL_REPRO_DIR'])
entry = pathlib.Path.cwd() / 'scripts' / 'rea.mjs'
for name, interrupt in [('control',False), ('interrupt1',True), ('interrupt2',True)]:
    destination = root / name
    with (root / (name+'.stdout')).open('w') as stdout, \
         (root / (name+'.stderr')).open('w') as stderr:
        child = subprocess.Popen(['node',str(entry),'export-web-scripts',
            str(root/'capture.json'),str(destination),'--json'],
            stdout=stdout,stderr=stderr)
        sent = False
        at_signal = None
        deadline = time.monotonic()+45
        try:
            while child.poll() is None:
                files = list((destination/'files').rglob('*.js'))
                if interrupt and files and not sent:
                    at_signal = {'scripts':len(files),
                        'manifest':(destination/'manifest.json').exists()}
                    child.send_signal(signal.SIGINT)
                    sent = True
                if time.monotonic() > deadline:
                    raise TimeoutError('export deadline')
                time.sleep(.001)
            status = child.wait()
        finally:
            if child.poll() is None:
                child.kill()
                child.wait()
    print(json.dumps({'run':name,'status':status,'at_signal':at_signal,
        'output_exists':destination.exists(),
        'scripts':len(list((destination/'files').rglob('*.js'))),
        'manifest':(destination/'manifest.json').exists(),
        'stdout_bytes':(root/(name+'.stdout')).stat().st_size}))

The executed driver also retried interrupt1 through the same CLI after the signal-terminated child exited. That retry returned status 1 and artifact_operation_failed with Extraction output root already exists. No live browser was needed; capture parsing and output creation, writes and publication were real.

Expected behavior

Before publication completes, handle the interrupt, propagate cancellation into the shared export workflow and await rollback of owned partial output. If rollback fails, report its residual output. The export contract applies to CLI and MCP; it promises partial-output removal on failure or cancellation.

This report concerns an unfinished export with no manifest, not cancellation after a completed atomic publication.

Impact and scope

P2: ordinary CLI interruption leaves an incomplete export and blocks retry to that destination. A caller can inspect and remove the abandoned output or choose a new destination before retrying. No pre-existing destination was overwritten. SIGINT reproduced twice; SIGTERM has the same missing handler by source inspection but was not executed. The MCP adapter supplies its request signal and is outside this adapter omission.

REA version

6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e. Relevant code remains unchanged at origin/main d8a9673fc44fde4bd30ff98c241886e6683580ca.

Runtime and platform

macOS 27.0.1 arm64; Node.js v26.12.0. Built CLI launched through node scripts/rea.mjs.

Target and artifact context

Synthetic valid saved browser-scenario capture: 160 distinct JavaScript URLs with about 64 KiB of retained source each; absent output destinations. No target execution or network request.

Interface

CLI

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed results, with signal dispatch synchronized to the first script's appearance:

control:    status=0,  scripts=160, manifest=true,  stdout nonempty
interrupt1: status=-2, scripts=1,   manifest=false, stdout empty
interrupt2: status=-2, scripts=1,   manifest=false, stdout empty

Python status -2 means termination by SIGINT, conventionally shell status 130. At both signal dispatches the destination contained one script and no manifest. Stderr was empty. The following invocation refused the retained destination.

Typed outcome or recovery expected

Forward the CLI interrupt to export_web_scripts, await owned rollback, and return its typed cancellation or actual cleanup-incomplete outcome rather than silently terminating before rollback.

Analysis or suspected cause

Source-derived: the CLI registration neither uses withCommandCancellation nor passes execution options to exportWebScripts. The MCP registration supplies its signal. The publisher already checks cancellation and releases the owned output tree when publication fails. A process terminated by the default SIGINT action cannot reach that asynchronous rollback.

Additional context

Related #5956 concerns rollback failure after cancellation is already supplied; #6042 concerns the cancellation cause after successful rollback; #6043 concerns a recovered capture close; #6029 concerns classification of an existing destination. Those require different fixes. No fix or restored behavior was tested. NOT_ENOUGH_INFO: introducing commit and last known good version.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6108 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area CLI ### Observed behavior Sending real SIGINT to the built `export-web-scripts` CLI during file publication terminates the process and leaves owned partial output. Two executions each retained one of 160 script files, no `manifest.json`, and no structured result. Retrying the same output directory fails because it already exists. The uninterrupted control exported all 160 scripts with a manifest and status 0. ### Reproduction Use the built source checkout with its locked dependencies. Set `REA_CANCEL_REPRO_DIR` to an existing disposable directory. Generate a valid saved capture with the repository's synthetic browser-scenario fixture: ```sh node --import tsx --input-type=module <<'JS' import {writeFile} from 'node:fs/promises'; import {join} from 'node:path'; import {scriptScenarioFixture} from './tests/fixtures/webScriptCapture.ts'; const scripts = Array.from({length:160},(_,i)=>({ url:`https://fixture.test/app/script-${i}.js`, bytes:Buffer.from(`export const marker${i} = ${i};\n`+' '.repeat(65536)), })); await writeFile(join(process.env.REA_CANCEL_REPRO_DIR,'capture.json'), JSON.stringify(scriptScenarioFixture(scripts))); JS ``` Run this driver from the checkout root. It waits for actual script output before sending the signal; it does not modify REA or replace filesystem operations. Use an empty reproduction directory so the three destination names are absent. ```python import json, os, pathlib, signal, subprocess, time root = pathlib.Path(os.environ['REA_CANCEL_REPRO_DIR']) entry = pathlib.Path.cwd() / 'scripts' / 'rea.mjs' for name, interrupt in [('control',False), ('interrupt1',True), ('interrupt2',True)]: destination = root / name with (root / (name+'.stdout')).open('w') as stdout, \ (root / (name+'.stderr')).open('w') as stderr: child = subprocess.Popen(['node',str(entry),'export-web-scripts', str(root/'capture.json'),str(destination),'--json'], stdout=stdout,stderr=stderr) sent = False at_signal = None deadline = time.monotonic()+45 try: while child.poll() is None: files = list((destination/'files').rglob('*.js')) if interrupt and files and not sent: at_signal = {'scripts':len(files), 'manifest':(destination/'manifest.json').exists()} child.send_signal(signal.SIGINT) sent = True if time.monotonic() > deadline: raise TimeoutError('export deadline') time.sleep(.001) status = child.wait() finally: if child.poll() is None: child.kill() child.wait() print(json.dumps({'run':name,'status':status,'at_signal':at_signal, 'output_exists':destination.exists(), 'scripts':len(list((destination/'files').rglob('*.js'))), 'manifest':(destination/'manifest.json').exists(), 'stdout_bytes':(root/(name+'.stdout')).stat().st_size})) ``` The executed driver also retried `interrupt1` through the same CLI after the signal-terminated child exited. That retry returned status 1 and `artifact_operation_failed` with `Extraction output root already exists`. No live browser was needed; capture parsing and output creation, writes and publication were real. ### Expected behavior Before publication completes, handle the interrupt, propagate cancellation into the shared export workflow and await rollback of owned partial output. If rollback fails, report its residual output. The [export contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/website-script-export.md#L3-L6) applies to CLI and MCP; it [promises partial-output removal on failure or cancellation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/website-script-export.md#L30-L34). This report concerns an unfinished export with no manifest, not cancellation after a completed atomic publication. ### Impact and scope P2: ordinary CLI interruption leaves an incomplete export and blocks retry to that destination. A caller can inspect and remove the abandoned output or choose a new destination before retrying. No pre-existing destination was overwritten. SIGINT reproduced twice; SIGTERM has the same missing handler by source inspection but was not executed. The MCP adapter supplies its request signal and is outside this adapter omission. ### REA version 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. Relevant code remains unchanged at origin/main `d8a9673fc44fde4bd30ff98c241886e6683580ca`. ### Runtime and platform macOS 27.0.1 arm64; Node.js v26.12.0. Built CLI launched through `node scripts/rea.mjs`. ### Target and artifact context Synthetic valid saved browser-scenario capture: 160 distinct JavaScript URLs with about 64 KiB of retained source each; absent output destinations. No target execution or network request. ### Interface CLI ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Observed results, with signal dispatch synchronized to the first script's appearance: ```text control: status=0, scripts=160, manifest=true, stdout nonempty interrupt1: status=-2, scripts=1, manifest=false, stdout empty interrupt2: status=-2, scripts=1, manifest=false, stdout empty ``` Python status `-2` means termination by SIGINT, conventionally shell status 130. At both signal dispatches the destination contained one script and no manifest. Stderr was empty. The following invocation refused the retained destination. ### Typed outcome or recovery expected Forward the CLI interrupt to `export_web_scripts`, await owned rollback, and return its typed cancellation or actual cleanup-incomplete outcome rather than silently terminating before rollback. ### Analysis or suspected cause Source-derived: [the CLI registration](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/webScriptCommands.ts#L31-L40) neither uses `withCommandCancellation` nor passes execution options to `exportWebScripts`. [The MCP registration](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerWebScriptTool.ts#L34-L41) supplies its signal. [The publisher](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/browser/assets/PublishWebScripts.ts#L69-L146) already checks cancellation and releases the owned output tree when publication fails. A process terminated by the default SIGINT action cannot reach that asynchronous rollback. ### Additional context Related #5956 concerns rollback failure after cancellation is already supplied; #6042 concerns the cancellation cause after successful rollback; #6043 concerns a recovered capture close; #6029 concerns classification of an existing destination. Those require different fixes. No fix or restored behavior was tested. NOT_ENOUGH_INFO: introducing commit and last known good version.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3739
No description provided.