From 99a4d0eff8431898d7638fefff305591fe326ed5 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 22 Oct 2026 01:25:29 +0800 Subject: [PATCH] fix: batch notification audits and bump CLI to 0.1.3 --- cli/package-lock.json | 4 +- cli/package.json | 2 +- sample/notify.py | 24 ++++++++--- shell/preload/notificationAudit.js | 24 +++++++---- test/back/script-notification-rpc.test.cjs | 46 +++++++++++++++++++++- 5 files changed, 81 insertions(+), 19 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 8ddc7b09..8bfa0e45 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@whyour/qinglong-cli", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@whyour/qinglong-cli", - "version": "0.1.2", + "version": "0.1.3", "license": "Apache-2.0", "bin": { "ql": "dist/npm/ql.js" diff --git a/cli/package.json b/cli/package.json index a8c931de..f2c148a6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@whyour/qinglong-cli", - "version": "0.1.2", + "version": "0.1.3", "description": "Authenticated remote management CLI for QingLong 2.x", "type": "commonjs", "bin": { diff --git a/sample/notify.py b/sample/notify.py index a19f1b2f..c969df2b 100644 --- a/sample/notify.py +++ b/sample/notify.py @@ -1313,30 +1313,40 @@ def add_notify_function(): def report_notification(channel, title, content, success): + report_notifications([(channel, success)], title, content) + + +def report_notifications(results, title, content): ql_dir = os.getenv("QL_DIR") - if not ql_dir: + if not ql_dir or not results: return - payload = {"title": str(title or "")[:512], "content": str(content or "")[:8192], + payload = [{"title": str(title or "")[:512], "content": str(content or "")[:8192], "success": success, "notifyType": channel, "scriptName": os.path.basename(__import__("sys").argv[0]), "taskId": os.getenv("ID", "")} + for channel, success in results] code = """const fs=require('node:fs'),path=require('node:path'); const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js')); report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});""" try: + # One Node startup/TLS connection per send, with room for cold startup + # before the reporter's bounded RPC deadline on busy Alpine hosts. subprocess.run(["node", "-e", code], input=json.dumps(payload), text=True, - capture_output=True, timeout=3) + capture_output=True, timeout=10) except Exception: pass # Audit availability never changes notification delivery. -def audited_notification(mode, title, content): +def audited_notification(mode, title, content, results=None): result = None try: result = mode(title, content) except Exception: result = False print(f"{mode.__name__} 推送失败") - report_notification(mode.__name__, title, content, result is True) + if results is None: + report_notification(mode.__name__, title, content, result is True) + else: + results.append((mode.__name__, result is True)) def send(title: str, content: str, ignore_default_config: bool = False, **kwargs): @@ -1362,12 +1372,14 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs content += "\n\n" + one() if hitokoto not in [False, "false"] else "" notify_function = add_notify_function() + results = [] ts = [ - threading.Thread(target=audited_notification, args=(mode, title, content), name=mode.__name__) + threading.Thread(target=audited_notification, args=(mode, title, content, results), name=mode.__name__) for mode in notify_function ] [t.start() for t in ts] [t.join() for t in ts] + report_notifications(results, title, content) def main(): diff --git a/shell/preload/notificationAudit.js b/shell/preload/notificationAudit.js index 0afddc1b..c1494d22 100644 --- a/shell/preload/notificationAudit.js +++ b/shell/preload/notificationAudit.js @@ -27,14 +27,22 @@ module.exports = async function reportNotificationAudit(payload) { credentials, { 'grpc.enable_http_proxy': 0 }, ); - await new Promise((resolve) => { - client.RecordNotifyLog( - payload, - new grpc.Metadata(), - { deadline: new Date(Date.now() + 1500) }, - () => resolve(), - ); - }); + // Python sends all channel results together so they share startup and TLS. + const records = Array.isArray(payload) ? payload : [payload]; + const deadline = new Date(Date.now() + 5000); + await Promise.all( + records.map( + (record) => + new Promise((resolve) => { + client.RecordNotifyLog( + record, + new grpc.Metadata(), + { deadline }, + () => resolve(), + ); + }), + ), + ); } catch (_) { // Missing certificates, old panels and offline services are best effort. } finally { diff --git a/test/back/script-notification-rpc.test.cjs b/test/back/script-notification-rpc.test.cjs index 5f06c7f2..e99013cd 100644 --- a/test/back/script-notification-rpc.test.cjs +++ b/test/back/script-notification-rpc.test.cjs @@ -29,7 +29,9 @@ test('JS and Python script reports reach the same authenticated RPC with bounded server.addService(Api.service, { RecordNotifyLog: (call, callback) => { received.push(call.request); - callback(null, { code: 200 }); + if (call.request.title === 'Concurrent cold startup') + setTimeout(() => callback(null, { code: 200 }), 2000); + else callback(null, { code: 200 }); }, }); const credentials = grpc.ServerCredentials.createSsl( @@ -91,6 +93,46 @@ n.send('Python failure','body') ); assert.ok(received.every((x) => x.taskId === '9')); assert.ok(!JSON.stringify(received).includes('private-password')); + // A busy host can spend longer than the former 3s timeout starting Node. + // All channels must still arrive, using one child and one TLS connection. + const coldStart = path.join(data, 'cold-start.cjs'); + fs.writeFileSync( + coldStart, + 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,3500);', + ); + const concurrent = `import importlib.util,sys,types,os,threading,json,subprocess +sys.modules['requests']=types.SimpleNamespace() +spec=importlib.util.spec_from_file_location('notify',os.path.join(os.environ['QL_DIR'],'sample/notify.py')) +n=importlib.util.module_from_spec(spec);spec.loader.exec_module(n) +barrier=threading.Barrier(3) +def accepted(title,content): + barrier.wait(timeout=5);return True +def rejected(title,content): + barrier.wait(timeout=5);raise RuntimeError('private-password') +def no_result(title,content): + barrier.wait(timeout=5) +n.add_notify_function=lambda:[accepted,rejected,no_result] +original=subprocess.run +children=[] +def tracked(*args,**kwargs): + children.append(json.loads(kwargs['input'])) + return original(*args,**kwargs) +n.subprocess.run=tracked +n.send('Concurrent cold startup','body') +assert len(children)==1,children +assert len(children[0])==3,children +`; + await run('python3', ['-c', concurrent], { + env: { ...env, NODE_OPTIONS: `--require=${coldStart}` }, + timeout: 14000, + }); + assert.deepEqual( + received + .filter((x) => x.title === 'Concurrent cold startup') + .map((x) => [x.notifyType, x.success]) + .sort(), + [['accepted', true], ['no_result', false], ['rejected', false]], + ); // Missing certificates and an older/unavailable backend must not kill scripts. await run( process.execPath, @@ -110,5 +152,5 @@ n.send('Python failure','body') ], { env, timeout: 8000 }, ); - assert.ok(Date.now() - started < 5000); + assert.ok(Date.now() - started < 7000); });