JRuby 多线程反引号偶发 127:GC 提前释放了 posix_spawn 的 argv

背景

一条集成测试用 JRuby 并发执行外部命令:开 5 个线程,每个线程用反引号跑同一个转换工具,最后断言 5 个返回码都正常。绝大多数时候它都能通过,但 CI 上偶尔会红一次,结果是:

text 复制代码
[127, 50, 50, 50, 50]

(这里 50 是该工具约定的正常返回码。)

127 在 shell 里的意思是 command not found。可这 5 路执行的是同一条命令,另外 4 路都正常,单独跑也从来没失败过。本地反复跑,就是复现不出来。

这篇文章记录了从定位到稳定复现的过程、背后的原理和规避方法,文末附完整的复现脚本、Java agent 源码和日志。

结论先说:这是 JRuby 底层 jnr 库的一个 use-after-free 问题,GC 在子进程读完参数之前,就把命令串所在的 native 内存释放了。

1. 现场线索

CI 的 stderr 里有这样两行:

text 复制代码
sh: line 1: $'\214\177...': command not found

两个线索:

  1. sh 收到的命令名是乱码。 不是工具找不到,是 shell 拿到的命令串本身已经坏了。
  2. 乱码里有 \177,也就是 0x7f。 在 x86-64 Linux 上,堆地址通常长成 0x00007fxxxxxxxxxx,按小端序存放时,高位字节就是 \177。命令串的位置被写进了一个指针,这是典型的"内存已释放、又被分配器拿去记账"的痕迹。

另外,失败的那一路几乎不耗时:工具根本没启动,sh 在解析命令名时就退出了。

2. 先补一点 GC 基础

GC(Garbage Collection,垃圾回收)是 JVM 自动回收内存的机制。它判断对象是否还活着,只看还有没有引用指向它,不看你"打不打算继续用"。GC 什么时候跑,程序没法精确控制。

JVM 调用 C 函数时,需要把数据放到 native 内存 ,也就是 malloc 出来的普通 C 内存,再把地址交给 C 函数。这块内存一般由一个 Java 对象"代表":Java 对象被 GC 回收时,它的清理逻辑(finalizer / Cleaner)会调用 free。

打个比方:你把一张写着命令的纸条递给同事,自己手里没留底。同事还没看清,保洁阿姨(GC)看到"这张纸没人拿着",就收走了;回收站又在纸上乱写了几个字。同事最后看到的,就是一串乱码。

于是有一条铁律:C 函数还在用这块内存的时候,代表它的 Java 对象必须一直被引用着。 否则 GC 一来,内存就被 free 了。

3. JRuby 执行反引号的调用链

在 JRuby 里执行 cmd 2>&1,大致会走:

text 复制代码
Kernel#`(backquote)
 → 命令里有 > & 等 shell 元字符,决定通过 /bin/sh -c 执行
 → jnr-posix: posix_spawnp("/bin/sh", ["sh", "-c", cmd], envp)
 → jnr-ffi: CharSequenceArrayParameterConverter.toNative
      把 argv 里每个字符串拷进 native 内存,生成一个 StringArray,返回它的地址
 → glibc posix_spawnp → 子进程 sh 读取 argv → execve

问题出在 toNative 生成的 StringArray:它把地址交给 native 调用之后,Java 侧不再持有它的引用。jnr-ffi 的这个转换器没有实现 PostInvocation,不会在调用结束前保持对象存活。

如果 GC 恰好发生在这个窗口里:

text 复制代码
时间 ─────────────────────────────────────────────>
Java 线程:  argv 拷到 native 内存 → 交出地址 → [等待 posix_spawnp]
GC:                                    ↑ StringArray 不可达,被回收,finalizer free 掉内存
glibc:                                   ↑ 往刚 free 的块里写空闲链表指针
子进程 sh:                                        ↑ 读 argv,读到的是指针字节

结果就是:

  • 命令串被改写:sh 报 command not found,返回 127;
  • 命令串里恰好混进反引号之类的字符:syntax error,返回 2;
  • 被释放的是 argv 指针数组本身:Errno::EFAULT: Bad address。

因为报错发生在 sh 解析命令名阶段,命令末尾的 2>&1 还没生效,所以反引号捕获不到任何输出,报错直接打在了父进程的 stderr 上。

4. 什么是"竞争窗口",以及"加 sleep 拉大窗口"

4.1 竞争窗口

上面时序图里,从"Java 交出地址、不再引用 StringArray"到"子进程 sh 读完 argv",中间有一段时间。这段时间里内存已经可以被回收,但还有人要读它。只要 GC 落在这段时间里,就会出错;落在外面就什么事都没有。 这段时间就叫竞争窗口(race window)。

这个窗口非常短。实测把 GC 往后推 100 微秒,就几乎碰不上了,所以它是微秒到毫秒级的。这就是问题偶发的根本原因:GC 每次都正常运行,只是极少数时候恰好落进这个窄缝里。

4.2 "加 sleep 拉大窗口"是什么

这是调试多线程 bug 的经典手法。假设你怀疑线程 A 在第 1 步和第 2 步之间有个漏洞,线程 B 要正好插进来才会出错。窗口只有 1 微秒,可能跑一万次才碰上一次。那就在第 1 步和第 2 步之间临时加一句 sleep(0.1),把窗口从 1 微秒拉到 100 毫秒,线程 B 几乎每次都能插进来,bug 就变得稳定可复现。

text 复制代码
原本:  A: 第1步 |1µs| 第2步          B 要正好插在这 1µs 里
加 sleep:A: 第1步 |---- sleep 100ms ----| 第2步   B 很容易插进来

它只是把本来就存在的窗口拉长,不制造新的错误,所以被认为是合法的复现手段。

4.3 这次为什么不能直接加 sleep

这次的窗口在 jnr 的 native 调用内部:Java 交出地址之后,执行就进了 C 代码,posix_spawnp 什么时候 fork、子进程什么时候读 argv,都在 C 和内核里。没法在中间插 sleep,也不想改 JRuby。

所以反过来做:窗口拉不长,就让 GC 准时出现在窗口开始的那一刻。 这就是下面 Java agent 做的事。它和加 sleep 是同一个思路,都是放大本来就存在的竞争,不改写内存、不伪造错误。

注意区分:复现脚本里的假命令 sleep 0.2; /bin/echo ... 中的 sleep 0.2,和上面说的"加 sleep 拉大窗口"无关。它只是让子进程跑久一点,模拟真实工具的耗时。

5. 为什么本地复现不了

要出错,必须同时满足:

  • 某个线程正处在 posix_spawnp 的竞争窗口里;
  • GC 正好在这个窗口里完成回收,finalizer 也执行了 free。

这两件事各自都很常见,但碰到同一瞬间的概率很低。CI 机器上同一个 JVM 里跑了大量用例,垃圾多,GC 频繁,所以偶尔会撞上;本地只跑一条用例,几乎碰不到。

6. 复现过程

6.1 概率复现:后台狂跑 GC

开 5 个线程,用反引号循环执行一条超过 256 字节的命令;另开一个线程不停调用 GC。完整脚本见附录 A.1。

我在 JRuby 9.2 上大约每 1000 次调用命中 0--3 次,在 JRuby 10.0 上更容易一些。能出现,但很难稳定。

6.2 卡时机:用 Java agent 把 GC 放到窗口开始的时刻

写了一个很小的 Java agent,用 JDK 自带的 instrument 机制,只在 CharSequenceArrayParameterConverter.toNative 返回前插一句调用,唤醒一个后台线程立刻执行 GC。完整源码见附录 B。

agent 不读写任何内存。 释放仍然是 jnr 自己的 finalizer 做的,agent 只是把原本随机的 GC 挪到了窗口里。

效果对比(JRuby 9.2,每组 1000 次调用):

条件 命中次数
不挂 agent 0
挂 agent 10--14 次,大部分是 127
挂 agent,但 GC 前延迟 ≥100 µs 基本为 0
挂 agent,模拟真实节奏(5 线程、每轮各调一次、子进程耗时) 5--17 次,几乎全是 127

最后用真实的转换命令、和测试完全相同的写法跑,第 97 轮打出了 127,日志见附录 C。乱码特征和 CI 完全一致。

这一轮 5 路全挂,是因为 agent 把 GC 卡在了 5 个线程同时 spawn 的时刻,一次 GC 把所有人的 argv 一起回收了。CI 上 GC 是自然发生的,只碰巧落在一路的窗口里,所以只坏了一路。

6.3 两个反直觉的发现

  • 限制 CPU 反而更难复现。 我本以为 CPU 紧张会让子进程更晚读 argv、窗口更长。可实测绑到 2 个核以后,4000 次调用一次都没命中。这个问题要求 GC 线程和 spawn 线程真正同时在跑,核少了只能轮流执行,反而撞不上。
  • 把堆调小帮助不大。 关键在 GC 发生的时机,不在 GC 的频率。

7. 影响范围

写法 是否受影响
反引号、%x{} 是(已验证)
Process.spawn 是(已验证,GC 压力下出现过 EFAULT)
system、IO.popen 很可能是(推断走同一路径,未单独验证)
java.lang.ProcessBuilder 否(不经过 jnr)
CRuby(MRI) 否,没有 JVM GC,也不经过 jnr

我测过的 JRuby 9.2.21.0(jnr-ffi 2.2.11)和 JRuby 10.0.2.0(jnr-ffi 2.2.17)都有这个问题,所以升级 JRuby 不能直接解决。

8. 怎么避免

8.1 推荐:在 JRuby 里改用 ProcessBuilder 启动子进程

JDK 的 ProcessBuilder 走 JDK 自己的进程创建代码,参数在创建子进程期间由 JDK 持有,不经过 jnr,也就没有这个窗口。把原来的反引号版本:

ruby 复制代码
def run_capture(cmd)
  output = `#{cmd} 2>&1`
  return $?.exitstatus, output
end

换成:

ruby 复制代码
require "java"

def run_capture(cmd)
  pb = java.lang.ProcessBuilder.new("/bin/sh", "-c", cmd)
  pb.redirectErrorStream(true)          # 相当于 2>&1
  process = pb.start
  out = String.from_java_bytes(process.getInputStream.readAllBytes)
  return process.waitFor, out
end

调用方不用改,返回值仍然是 [退出码, 输出]。如果不需要 shell 特性(管道、重定向、通配符),更好的做法是直接传 argv,连 shell 解析都省掉:

ruby 复制代码
pb = java.lang.ProcessBuilder.new("/path/to/tool", "--input", input, "--output", output)

验证情况:在 JRuby 10.0 上,后台线程持续强制 GC 的压力下,ProcessBuilder 版本跑了 3 轮各 1000 次,0 次出错;同样条件下,反引号版本每轮命中 3--31 次。用真实工具五路并行,结果全部正常。还没有在 agent 卡时机的条件下验证。

8.2 不要用 Process.spawn 来"修"

Process.spawn 在 JRuby 里同样走 jnr 的 posix_spawnp,GC 压力下出现过 EFAULT。从反引号换成 Process.spawn,只是换了个写法,问题还在。

8.3 加锁串行化 spawn:不能当修复

直觉上会想:给反引号加一把 Mutex,让 5 个线程排队 spawn,是不是就好了?这能减少"一个线程的 GC 撞上另一个线程的窗口",但 GC 的来源不只是这几个线程,JVM 里任何地方分配内存都可能触发 GC,单个 spawn 的窗口依然存在。所以它最多降低概率,不能消除问题,而且会让并行测试变成串行,失去测试意义。我没有用数据验证过它的效果,不推荐。

8.4 测试代码里顺手要改的

  • 多线程收集结果要线程安全。 复现那一轮 5 路全部失败,结果数组里却只有 4 个值:

text results=[127, 127, 2, 127]

原因是多个线程同时往同一个 Array 里 <<。CRuby 有 GIL 兜着,问题不明显;JRuby 的线程是真并行的,Array 不是线程安全的,并发追加会丢元素。改法:

ruby results = threads.map(&:value) # 每个线程把 rc 作为返回值 # 或者 lock = Mutex.new lock.synchronize { results << rc }

  • 失败时把每一路的输出打出来。 原测试把输出丢掉了(rc, _out = ...),CI 失败时只看得到 [127, 50, ...],看不到是哪一路、报了什么。断言信息里带上失败那一路的输出,下次一眼就能定位。

8.5 如果你是写 native 绑定的库作者

根本原则:把内存地址交给 C 函数期间,代表这块内存的 Java 对象必须保持可达。 常见做法:

  • native 调用结束后再用一次这个对象,或者调用 java.lang.ref.Reference.reachabilityFence(obj),告诉 JIT 和 GC 它在这之前一直活着;
  • 在 jnr-ffi 里实现 ToNativeConverter.PostInvocation,让框架在调用结束后才释放参数对象。

8.6 上报上游

这是 jnr-ffi / JRuby 的缺陷,附上附录里的复现脚本和 agent,可以向上游提 issue,从根上修。在上游修复之前,业务代码按 8.1 绕开。

9. 排查经验小结

  1. 先看退出码和耗时。 127 加上几乎为 0 的耗时,说明命令根本没执行,不要去查被调用的程序。
  2. 乱码里有 \177 / 0x7f,就往 use-after-free 的方向想。 这是 64 位堆指针的高位字节。
  3. "本地复现不了"不等于"环境问题"。 时序类 bug 要主动放大条件:加并发、加 GC 压力、卡准时机。
  4. 区分"复现"和"制造问题"。 放大自然条件(更多 GC、卡准时机、拉大窗口)是复现;直接改写内存只能验证后果,证明不了成因。
  5. 确认实际运行的版本。 测试框架里的子进程用的 JRuby 版本,可能和你 PATH 里的不一样。我一开始就分析错了版本。

一句话总结:GC 只认引用,不认意图。 交给 C 函数的内存,只要 Java 侧手里没拿着,就可能被提前收走。


附录 A:复现脚本(Ruby)

运行环境:JRuby 9.2.21.0,Java 21。

A.1 概率复现:后台狂跑 GC(repro_backtick_gc.rb)

ruby 复制代码
require 'java'

LONG = "/tmp/" + ("p" * 300)
stop = false
hits = []
lock = Mutex.new

gc = Thread.new do
  until stop
    java.lang.System.gc
    java.lang.System.runFinalization
    Array.new(2000) { "x" * 100 }
  end
end

workers = 5.times.map do |i|
  Thread.new do
    200.times do |n|
      cmd = "/bin/echo OK #{i}-#{n} #{LONG} 2>&1"
      begin
        out = `#{cmd}`
        rc = $?.exitstatus
        lock.synchronize { hits << [i, n, rc, out] } if rc != 0 || !out.start_with?("OK")
      rescue => e
        lock.synchronize { hits << [i, n, e.class.to_s, e.message[0, 80]] }
      end
    end
  end
end
workers.each(&:join)
stop = true
gc.join

hits.first(5).each { |h| p h }
puts "hits=#{hits.size} of 1000"

运行:

bash 复制代码
JAVA_HOME=/usr/lib/jvm/java-21 jruby -J-Xmx64m repro_backtick_gc.rb

A.2 echo 连续调用,配合 agent(echo_timed.rb)

不开后台 GC 线程,GC 时机完全由 agent 控制。run_capture 和原测试的写法一致。

ruby 复制代码
def run_capture(cmd)
  output = `#{cmd} 2>&1`
  return $?.exitstatus, output
end

LONG = "/tmp/" + ("p" * 300)
hits = []
lock = Mutex.new
workers = 5.times.map do |i|
  Thread.new do
    200.times do |n|
      begin
        rc, out = run_capture("/bin/echo OK #{i}-#{n} #{LONG}")
        lock.synchronize { hits << [i, n, rc, out[0, 160]] } if rc != 0 || !out.start_with?("OK")
      rescue => e
        lock.synchronize { hits << [i, n, e.class.to_s, e.message[0, 80]] }
      end
    end
  end
end
workers.each(&:join)
hits.first(3).each { |h| p h }
puts "hits=#{hits.size} of 1000 rc127=#{hits.count { |h| h[2] == 127 }}"

运行:

bash 复制代码
JAVA_HOME=/usr/lib/jvm/java-21 jruby -J-javaagent:/path/to/crgc-agent.jar echo_timed.rb

A.3 模拟真实测试节奏(pattern_sim.rb)

每轮开 N 个线程,每个线程只调用一次,子进程先 sleep 0.2 模拟耗时,和原测试的调用节奏一致。

ruby 复制代码
def run_capture(cmd)
  output = `#{cmd} 2>&1`
  return $?.exitstatus, output
end

threads_n = (ENV["THREADS"] || "5").to_i
iters = (ENV["ITERS"] || "200").to_i
LONG = "/tmp/" + ("p" * 300)
calls = 0
bad = []
lock = Mutex.new
iters.times do |it|
  ths = threads_n.times.map do |i|
    Thread.new do
      begin
        rc, out = run_capture("sleep 0.2; /bin/echo OK #{it}-#{i} #{LONG}")
        lock.synchronize { bad << [it, i, rc, out[0, 120]] } if rc != 0
      rescue => e
        lock.synchronize { bad << [it, i, e.class.to_s] }
      end
    end
  end
  ths.each(&:join)
  calls += threads_n
end
bad.first(2).each { |b| p b }
puts "threads=#{threads_n} calls=#{calls} hits=#{bad.size} rc127=#{bad.count { |b| b[2] == 127 }}"

运行:

bash 复制代码
THREADS=5 ITERS=200 JAVA_HOME=/usr/lib/jvm/java-21 \
  jruby -J-javaagent:/path/to/crgc-agent.jar pattern_sim.rb

A.4 真实命令复现(real_cmd_repro.rb,已脱敏)

和原测试的写法一致:5 个线程,每轮各执行一次真实转换命令,期望返回码为 50。工具路径和参数已替换为占位符。

ruby 复制代码
require "fileutils"

CONVERTER   = "/path/to/converter"
INPUT_FILE  = "/path/to/input.dat"
KEY_FILE    = "/path/to/key"
EXPECTED_RC = 50

def run_capture(cmd)
  output = `#{cmd} 2>&1`
  return $?.exitstatus, output
end

def one_iteration
  threads = []
  results = []
  outputs = {}
  5.times do |i|
    threads << Thread.new do
      out_file = File.join("/tmp", "parallel_out_#{i}_#{Time.now.to_f}.bin")
      cmd = "#{CONVERTER} --input #{INPUT_FILE} --key #{KEY_FILE} --output #{out_file}"
      rc, out = run_capture(cmd)
      FileUtils.rm_f(out_file)
      results << rc                      # 保留原测试的写法(非线程安全,见 8.4)
      outputs[i] = out unless rc == EXPECTED_RC
    end
  end
  threads.each(&:join)
  [results, outputs]
end

max = (ENV["MAX_ITER"] || "100").to_i
max.times do |n|
  t0 = Time.now
  begin
    results, outputs = one_iteration
  rescue => e
    puts "iteration #{n + 1}: exception #{e.class}: #{e.message[0, 200]}"
    next
  end
  puts "iteration #{n + 1}: results=#{results.inspect} (#{(Time.now - t0).round(1)}s)"
  $stdout.flush
  next if results.all? { |rc| rc == EXPECTED_RC }
  outputs.each { |i, out| puts "  thread #{i} output: #{out.inspect[0, 600]}" }
  puts "Not all parallel executions returned rc=#{EXPECTED_RC}: #{results.inspect}"
  break if results.include?(127)
end

运行:

bash 复制代码
MAX_ITER=150 JAVA_HOME=/usr/lib/jvm/java-21 nice -n 19 \
  jruby -J-javaagent:/path/to/crgc-agent.jar real_cmd_repro.rb > real_run.log 2>&1

附录 B:卡 GC 时机的 Java agent 源码

三个类,只依赖 JDK 21 自带的 java.lang.instrument 和 JDK 内置的 ASM(jdk.internal.org.objectweb.asm)。

B.1 Agent.java

java 复制代码
package crgc;

import java.lang.instrument.Instrumentation;
import java.util.Map;
import java.util.Set;

public final class Agent {
    public static void premain(String args, Instrumentation inst) {
        Module self = Agent.class.getModule();
        Module javaBase = Object.class.getModule();
        // 让 agent 能访问 JDK 内置的 ASM
        inst.redefineModule(javaBase, Set.of(),
            Map.of("jdk.internal.org.objectweb.asm", Set.of(self)),
            Map.of(), Set.of(), Map.of());
        inst.addTransformer(new Transformer(inst));
        System.err.println("[crgc] agent installed");
    }
}

B.2 Transformer.java

java 复制代码
package crgc;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import java.util.Map;
import java.util.Set;

import jdk.internal.org.objectweb.asm.ClassReader;
import jdk.internal.org.objectweb.asm.ClassVisitor;
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.MethodVisitor;
import jdk.internal.org.objectweb.asm.Opcodes;

final class Transformer implements ClassFileTransformer {
    private static final String TARGET = "jnr/ffi/provider/converters/CharSequenceArrayParameterConverter";
    private static final String DESC = "([Ljava/lang/CharSequence;Ljnr/ffi/mapper/ToNativeContext;)Ljnr/ffi/Pointer;";
    private final Instrumentation inst;

    Transformer(Instrumentation inst) {
        this.inst = inst;
    }

    @Override
    public byte[] transform(Module module, ClassLoader loader, String className, Class<?> redefined,
                            ProtectionDomain pd, byte[] bytes) {
        if (!TARGET.equals(className)) return null;
        try {
            // jruby.jar 是具名模块,需要让它能读到 agent 所在的模块
            if (module.isNamed()) {
                inst.redefineModule(module, Set.of(Trigger.class.getModule()), Map.of(), Map.of(), Set.of(), Map.of());
            }
            ClassReader cr = new ClassReader(bytes);
            ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
            cr.accept(new ClassVisitor(Opcodes.ASM9, cw) {
                @Override
                public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] ex) {
                    MethodVisitor mv = super.visitMethod(access, name, desc, sig, ex);
                    if (!"toNative".equals(name) || !DESC.equals(desc)) return mv;
                    return new MethodVisitor(Opcodes.ASM9, mv) {
                        @Override
                        public void visitInsn(int opcode) {
                            // 在 toNative 返回前插入 Trigger.fire()
                            if (opcode == Opcodes.ARETURN) {
                                super.visitMethodInsn(Opcodes.INVOKESTATIC, "crgc/Trigger", "fire", "()V", false);
                            }
                            super.visitInsn(opcode);
                        }
                    };
                }
            }, 0);
            System.err.println("[crgc] instrumented " + className + " (module=" + module + ")");
            return cw.toByteArray();
        } catch (Throwable t) {
            System.err.println("[crgc] transform failed: " + t);
            return null;
        }
    }
}

B.3 Trigger.java

java 复制代码
package crgc;

import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;

/** jnr 生成 native argv 后,唤醒后台线程立刻执行一次真实的 GC + finalization。 */
public final class Trigger {
    private static final Semaphore PENDING = new Semaphore(0);
    public static final AtomicLong FIRES = new AtomicLong();
    public static final AtomicLong GCS = new AtomicLong();
    private static final long DELAY_NS = Long.getLong("crgc.delayUs", 0L) * 1000L;

    static {
        Thread t = new Thread(() -> {
            while (true) {
                PENDING.acquireUninterruptibly();
                PENDING.drainPermits();
                if (DELAY_NS > 0) LockSupport.parkNanos(DELAY_NS);
                System.gc();
                System.runFinalization();
                GCS.incrementAndGet();
            }
        }, "crgc-trigger");
        t.setDaemon(true);
        t.start();
        Runtime.getRuntime().addShutdownHook(new Thread(() ->
            System.err.println("[crgc] toNative fires=" + FIRES.get() + " gcs=" + GCS.get())));
    }

    private Trigger() {}

    public static void fire() {
        FIRES.incrementAndGet();
        PENDING.release();
    }
}

-Dcrgc.delayUs=<微秒> 可以在 GC 前加延迟,用来测窗口有多宽。

B.4 编译和打包

bash 复制代码
JH=/usr/lib/jvm/java-21
mkdir -p out
$JH/bin/javac --add-exports java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED -d out src/crgc/*.java
printf 'Premain-Class: crgc.Agent\n' > manifest.txt
$JH/bin/jar cfm crgc-agent.jar manifest.txt -C out .

使用时给 JRuby 加 -J-javaagent:/path/to/crgc-agent.jar 即可。

附录 C:关键日志(已脱敏)

C.1 真实命令复现(real_cmd_repro.rb,第 97 轮命中)

text 复制代码
[crgc] agent installed
[crgc] instrumented jnr/ffi/provider/converters/CharSequenceArrayParameterConverter (module=module org.jruby.dist)
iteration 1: results=[50, 50, 50, 50, 50] (277.6s)
iteration 2: results=[50, 50, 50, 50, 50] (288.3s)
...
iteration 95: results=[50, 50, 50, 50, 50] (80.0s)
iteration 96: results=[50, 50, 50, 50, 50] (79.7s)
sh: line 1: $'\360\f': command not found
sh: line 1: $'\360': command not found
sh: -c: line 1: unexpected EOF while looking for matching ``'
sh: -c: line 2: syntax error: unexpected end of file
sh: line 1: $'G\005$\244\177': command not found
sh: line 1: $'\360R': command not found
iteration 97: results=[127, 127, 2, 127] (0.1s)
  thread 1 output: ""
  thread 2 output: ""
  thread 3 output: ""
  thread 0 output: ""
  thread 4 output: ""
Not all parallel executions returned rc=50: [127, 127, 2, 127]
[crgc] toNative fires=970 gcs=245

怎么读:

  • 前 96 轮同一条命令都正常,第 97 轮 5 路命令串全被改写成乱码。
  • \244\177 是堆地址高位字节,说明这块内存被 glibc 当成空闲块写进了指针。
  • 本轮只用 0.1 秒,正常一轮约 80 秒,说明真实工具一次都没启动。
  • 一路返回 2:乱码里恰好有个反引号字符,触发了语法错误。
  • 5 路都失败,results 只有 4 个值:多线程 results << rc 丢了一个元素。
  • 每一路的 output 都是空的:报错发生在 2>&1 生效之前,打到了父进程的 stderr。

(中途有十几轮因为环境原因返回 1,与本问题无关,已从摘录中省略。)

C.2 echo 连续调用(echo_timed.rb)

text 复制代码
### without agent
hits=0 of 1000 rc127=0
### with agent round 1
sh: line 1: 0*: command not found
[2, 82, "Errno::EFAULT", "Bad address - /bin/echo OK 2-82 /tmp/ppppppppppppppppppppppppppppppppppppppppppp"]
hits=14 of 1000 rc127=6
### with agent round 2
sh: line 1: $'0\v\002\214t\177': command not found
[3, 149, 127, ""]
hits=13 of 1000 rc127=9
### with agent round 3
sh: line 1: PN: command not found
hits=10 of 1000 rc127=7

$'0\v\002\214t\177' 中的 \214...\177,和 CI 日志里的乱码是同一种特征。Errno::EFAULT 是被释放的恰好是 argv 指针数组本身。

C.3 GC 延迟对比(echo_timed.rb,-Dcrgc.delayUs)

text 复制代码
delayUs=0    : hits=6, 7
delayUs=100  : hits=0, 0
delayUs=300  : hits=1, 0
delayUs=1000 : hits=0, 0
delayUs=3000 : hits=0, 0

GC 只要晚 100 微秒,就几乎碰不上,说明竞争窗口在微秒级。

C.4 模拟真实节奏(pattern_sim.rb)

text 复制代码
[83, 4, 127, ""]
[83, 0, 127, ""]
threads=5 calls=1000 hits=17 rc127=16
threads=5 calls=1000 hits=5 rc127=5
threads=20 calls=1000 hits=15 rc127=11

[83, 4, 127, ""] 表示第 83 轮、4 号线程、返回 127、没有捕获到输出。同一轮里经常有多个线程一起中招。

相关推荐
咖啡八杯1 小时前
异常体系设计:三类策略(i18n code、message、受检异常)
java·架构·代码规范
广州灵眸科技有限公司1 小时前
瑞芯微(EASY EAI)RV1126B DeepSeek R1部署教程
linux·数据库·yolo
Spatial3Ddddd2 小时前
2026空间三维重建服务商横向对比
java·服务器·人工智能
沙老师2 小时前
linux比windows死板的地方
linux·运维·windows
H.莓飛2 小时前
【Linux】进程的创建与终止
linux·运维·服务器·c语言·visual studio
淡海水2 小时前
03-03-架构篇-SGen GC架构解析
unity·架构·c#·gc·sgen
blueSatchel2 小时前
关于dev下节点的创建
linux·嵌入式
老秦包你会2 小时前
Linux第十课---------进程间通信
linux
Rain的Java大神之路2 小时前
Java 三层架构,90% 的人只答对一半:Controller 不是瘦,Service 不是透传,Dao 不是只写 SQL
java·后端·面试